From ea9fbbaac01682247115c8a5cb63c6c84530108d Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 10:18:14 +0300 Subject: [PATCH 001/211] feat(client-generator): CodeWriter and language-neutral naming helpers --- .../authoring/__tests__/code-writer.test.ts | 27 ++++++++ .../src/authoring/__tests__/naming.test.ts | 31 +++++++++ .../src/authoring/code-writer.ts | 38 +++++++++++ .../client-generator/src/authoring/naming.ts | 66 +++++++++++++++++++ 4 files changed, 162 insertions(+) create mode 100644 packages/client-generator/src/authoring/__tests__/code-writer.test.ts create mode 100644 packages/client-generator/src/authoring/__tests__/naming.test.ts create mode 100644 packages/client-generator/src/authoring/code-writer.ts create mode 100644 packages/client-generator/src/authoring/naming.ts diff --git a/packages/client-generator/src/authoring/__tests__/code-writer.test.ts b/packages/client-generator/src/authoring/__tests__/code-writer.test.ts new file mode 100644 index 0000000000..b063b6bd4a --- /dev/null +++ b/packages/client-generator/src/authoring/__tests__/code-writer.test.ts @@ -0,0 +1,27 @@ +import { CodeWriter } from '../code-writer.js'; + +describe('CodeWriter', () => { + it('builds indented blocks in any language without manual whitespace bookkeeping', () => { + const writer = new CodeWriter(); + writer.line('class Pet:').indent(() => { + writer.line('def __init__(self):').indent(() => { + writer.line('self.name = name'); + }); + }); + expect(writer.toString()).toBe('class Pet:\n def __init__(self):\n self.name = name\n'); + }); + + it('block() wraps open/body/close; blank() emits an empty line without indentation', () => { + const writer = new CodeWriter(' '); + writer.block( + 'func main() {', + () => { + writer.line('run()'); + writer.blank(); + writer.line('done()'); + }, + '}' + ); + expect(writer.toString()).toBe('func main() {\n run()\n\n done()\n}\n'); + }); +}); diff --git a/packages/client-generator/src/authoring/__tests__/naming.test.ts b/packages/client-generator/src/authoring/__tests__/naming.test.ts new file mode 100644 index 0000000000..919a30b8f0 --- /dev/null +++ b/packages/client-generator/src/authoring/__tests__/naming.test.ts @@ -0,0 +1,31 @@ +import { casing, identifierFor, RESERVED_WORDS } from '../naming.js'; + +describe('casing', () => { + it('splits on delimiters and case boundaries, handling acronyms', () => { + for (const input of ['order-item', 'order_item', 'orderItem', 'OrderItem', 'order item']) { + expect(casing.camel(input)).toBe('orderItem'); + expect(casing.pascal(input)).toBe('OrderItem'); + expect(casing.snake(input)).toBe('order_item'); + expect(casing.screaming(input)).toBe('ORDER_ITEM'); + } + expect(casing.snake('APIKey')).toBe('api_key'); + expect(casing.pascal('api_key_v2')).toBe('ApiKeyV2'); + }); +}); + +describe('identifierFor', () => { + it('sanitizes invalid characters and leading digits, then applies the style', () => { + expect(identifierFor('2nd-item', { style: 'snake' })).toBe('_2nd_item'); + expect(identifierFor('user.name', { style: 'camel' })).toBe('userName'); + }); + + it('suffixes an underscore for reserved words of the target language', () => { + expect(identifierFor('class', { style: 'snake', reserved: RESERVED_WORDS.python })).toBe( + 'class_' + ); + expect(identifierFor('type', { style: 'camel', reserved: RESERVED_WORDS.go })).toBe('type_'); + expect(identifierFor('order', { style: 'camel', reserved: RESERVED_WORDS.python })).toBe( + 'order' + ); + }); +}); diff --git a/packages/client-generator/src/authoring/code-writer.ts b/packages/client-generator/src/authoring/code-writer.ts new file mode 100644 index 0000000000..080f0b28bb --- /dev/null +++ b/packages/client-generator/src/authoring/code-writer.ts @@ -0,0 +1,38 @@ +// A small indentation-aware text builder for emitting code in ANY language — +// deliberately not an AST. Part of the language-neutral authoring toolkit. + +export class CodeWriter { + private readonly lines: string[] = []; + private depth = 0; + + constructor(private readonly indentUnit: string = ' ') {} + + /** Append one line at the current depth; no argument appends an empty line. */ + line(text = ''): this { + this.lines.push(text === '' ? '' : this.indentUnit.repeat(this.depth) + text); + return this; + } + + blank(): this { + return this.line(); + } + + /** Run `body` with the depth increased by one. */ + indent(body: () => void): this { + this.depth++; + body(); + this.depth--; + return this; + } + + /** `open` at the current depth, `body` indented, `close` back at the current depth. */ + block(open: string, body: () => void, close: string): this { + this.line(open); + this.indent(body); + return this.line(close); + } + + toString(): string { + return this.lines.join('\n') + '\n'; + } +} diff --git a/packages/client-generator/src/authoring/naming.ts b/packages/client-generator/src/authoring/naming.ts new file mode 100644 index 0000000000..d0d38d0893 --- /dev/null +++ b/packages/client-generator/src/authoring/naming.ts @@ -0,0 +1,66 @@ +// Language-neutral naming: one word splitter, four casings, and an identifier +// sanitizer parameterized by the target language's reserved words. TypeScript +// keeps its specialized sanitizer in emitters/identifier.ts; this is for the +// other output languages. + +/** Split on delimiters and camel/acronym boundaries: 'APIKey-v2' → ['api', 'key', 'v2']. */ +function splitWords(name: string): string[] { + return name + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .split(/[^A-Za-z0-9]+/) + .filter((word) => word !== '') + .map((word) => word.toLowerCase()); +} + +const capitalize = (word: string) => word.charAt(0).toUpperCase() + word.slice(1); + +export const casing = { + camel: (name: string): string => { + const [first, ...rest] = splitWords(name); + return (first ?? '') + rest.map(capitalize).join(''); + }, + pascal: (name: string): string => splitWords(name).map(capitalize).join(''), + snake: (name: string): string => splitWords(name).join('_'), + screaming: (name: string): string => splitWords(name).join('_').toUpperCase(), +}; + +/** Keyword sets for the first-party target languages; authors pass their own set for others. */ +export const RESERVED_WORDS: Record<'typescript' | 'python' | 'go', ReadonlySet> = { + // prettier-ignore + typescript: new Set([ + 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', + 'do', 'else', 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', + 'import', 'in', 'instanceof', 'new', 'null', 'return', 'super', 'switch', 'this', 'throw', + 'true', 'try', 'typeof', 'var', 'void', 'while', 'with', 'implements', 'interface', 'let', + 'package', 'private', 'protected', 'public', 'static', 'yield', 'await', + ]), + // prettier-ignore + python: new Set([ + 'false', 'none', 'true', 'and', 'as', 'assert', 'async', 'await', 'break', 'class', + 'continue', 'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global', + 'if', 'import', 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return', + 'try', 'while', 'with', 'yield', 'match', 'case', 'type', + ]), + // prettier-ignore + go: new Set([ + 'break', 'case', 'chan', 'const', 'continue', 'default', 'defer', 'else', 'fallthrough', + 'for', 'func', 'go', 'goto', 'if', 'import', 'interface', 'map', 'package', 'range', + 'return', 'select', 'struct', 'switch', 'type', 'var', + ]), +}; + +/** + * A safe identifier for any C-like or snake-case language: applies the casing + * style (which strips invalid characters), prefixes `_` when the result starts + * with a digit, and suffixes `_` when it is a reserved word — the cross-language + * convention (Python's `class_`, Go's `type_`). + */ +export function identifierFor( + name: string, + options: { style?: keyof typeof casing; reserved?: ReadonlySet } = {} +): string { + const styled = casing[options.style ?? 'camel'](name); + const base = styled === '' ? '_' : /^[0-9]/.test(styled) ? `_${styled}` : styled; + return options.reserved?.has(base.toLowerCase()) ? `${base}_` : base; +} From 75f73765a585800a6c2a5f0ad508407dd02b14fb Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 10:20:28 +0300 Subject: [PATCH 002/211] feat(client-generator): language-neutral schema helpers (flattenAllOf, discriminatorCases, nullability, enums) --- .../src/authoring/__tests__/schema.test.ts | 111 ++++++++++++++++++ .../client-generator/src/authoring/schema.ts | 94 +++++++++++++++ 2 files changed, 205 insertions(+) create mode 100644 packages/client-generator/src/authoring/__tests__/schema.test.ts create mode 100644 packages/client-generator/src/authoring/schema.ts diff --git a/packages/client-generator/src/authoring/__tests__/schema.test.ts b/packages/client-generator/src/authoring/__tests__/schema.test.ts new file mode 100644 index 0000000000..71c5fff960 --- /dev/null +++ b/packages/client-generator/src/authoring/__tests__/schema.test.ts @@ -0,0 +1,111 @@ +import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js'; +import { + discriminatorCases, + docText, + enumValues, + flattenAllOf, + isNullable, + unwrapNullable, +} from '../schema.js'; + +const STRING: SchemaModel = { kind: 'scalar', scalar: 'string' }; + +function model(schemas: Record): ApiModel { + return { + title: 't', + version: '1', + services: [], + schemas: Object.entries(schemas).map(([name, schema]) => ({ name, schema })), + securitySchemes: [], + } as unknown as ApiModel; +} + +describe('flattenAllOf', () => { + it('merges intersection members across refs; later members win on property conflicts', () => { + const collection: SchemaModel = { + kind: 'object', + properties: [ + { name: 'offset', schema: { kind: 'scalar', scalar: 'integer' }, required: false }, + { name: 'kind', schema: STRING, required: false }, + ], + }; + const listPage: SchemaModel = { + kind: 'intersection', + members: [ + { kind: 'ref', name: 'Collection' }, + { + kind: 'object', + properties: [ + { name: 'items', schema: { kind: 'array', items: STRING }, required: true }, + { name: 'kind', schema: { kind: 'literal', value: 'list' }, required: true }, + ], + }, + ], + }; + const flat = flattenAllOf(listPage, model({ Collection: collection }))!; + const names = flat.properties.map((property) => property.name); + expect(names).toEqual(['offset', 'kind', 'items']); + const kind = flat.properties.find((property) => property.name === 'kind')!; + expect(kind.schema).toEqual({ kind: 'literal', value: 'list' }); + expect(kind.required).toBe(true); + }); + + it('flattens a plain object and nested intersections; bails to undefined on a scalar member', () => { + const object: SchemaModel = { kind: 'object', properties: [] }; + expect(flattenAllOf(object, model({}))).toEqual({ properties: [], description: undefined }); + const withScalar: SchemaModel = { kind: 'intersection', members: [object, STRING] }; + expect(flattenAllOf(withScalar, model({}))).toBeUndefined(); + const nested: SchemaModel = { + kind: 'intersection', + members: [{ kind: 'intersection', members: [object] }], + }; + expect(flattenAllOf(nested, model({}))).toEqual({ properties: [], description: undefined }); + }); +}); + +describe('discriminatorCases', () => { + it('returns the neutral dispatch table with each case schema resolved', () => { + const cat: SchemaModel = { kind: 'object', properties: [] }; + const union: SchemaModel = { + kind: 'union', + members: [{ kind: 'ref', name: 'Cat' }], + discriminator: { propertyName: 'petType', mapping: [{ value: 'cat', schemaName: 'Cat' }] }, + }; + expect(discriminatorCases(union, model({ Cat: cat }))).toEqual({ + property: 'petType', + cases: [{ value: 'cat', schemaName: 'Cat', schema: cat }], + }); + expect(discriminatorCases({ kind: 'union', members: [] }, model({}))).toBeUndefined(); + }); +}); + +describe('nullability and enums', () => { + it('detects and strips null union members', () => { + const nullable: SchemaModel = { kind: 'union', members: [STRING, { kind: 'null' }] }; + expect(isNullable(nullable)).toBe(true); + expect(unwrapNullable(nullable)).toEqual(STRING); + expect(isNullable(STRING)).toBe(false); + expect(unwrapNullable(STRING)).toBe(STRING); + }); + + it('extracts enum values with SCREAMING member-name suggestions', () => { + const status: SchemaModel = { + kind: 'enum', + values: ['in-progress', 'done', 404], + scalar: 'string', + }; + expect(enumValues(status)).toEqual({ + values: ['in-progress', 'done', 404], + scalar: 'string', + memberNames: ['IN_PROGRESS', 'DONE', 'VALUE_404'], + }); + expect(enumValues(STRING)).toBeUndefined(); + }); +}); + +describe('docText', () => { + it('normalizes a description into trimmed lines, dropping blank edges', () => { + expect(docText(' First line.\r\n\r\nSecond.\n')).toEqual(['First line.', '', 'Second.']); + expect(docText(undefined)).toEqual([]); + }); +}); diff --git a/packages/client-generator/src/authoring/schema.ts b/packages/client-generator/src/authoring/schema.ts new file mode 100644 index 0000000000..0bbe1733d6 --- /dev/null +++ b/packages/client-generator/src/authoring/schema.ts @@ -0,0 +1,94 @@ +// Language-neutral schema helpers: the cross-language variance points (allOf, +// discriminators, nullability, enums) exposed as pure functions over the IR, so +// a generator in ANY output language never re-implements schema semantics. + +import type { ApiModel, PropertyModel, SchemaModel } from '../intermediate-representation/model.js'; +import { casing } from './naming.js'; + +/** Follow a `ref` chain through the model's named schemas; undefined on a miss or cycle. */ +function deref(schema: SchemaModel, model: ApiModel): SchemaModel | undefined { + const seen = new Set(); + let current = schema; + while (current.kind === 'ref') { + if (seen.has(current.name)) return undefined; + seen.add(current.name); + const named = model.schemas.find((s) => s.name === current.name); + if (named === undefined) return undefined; + current = named.schema; + } + return current; +} + +/** + * The flattened view of an object or `allOf` composition — what every language + * without intersection types renders. Later members win on property-name + * conflicts (allOf refinement); returns undefined when a member is not an + * object (nothing coherent to flatten). + */ +export function flattenAllOf( + schema: SchemaModel, + model: ApiModel +): { properties: PropertyModel[]; description?: string } | undefined { + const resolved = deref(schema, model); + if (resolved === undefined) return undefined; + if (resolved.kind === 'object') { + return { properties: resolved.properties, description: resolved.description }; + } + if (resolved.kind !== 'intersection') return undefined; + const merged = new Map(); + for (const member of resolved.members) { + const flat = flattenAllOf(member, model); + if (flat === undefined) return undefined; + for (const property of flat.properties) merged.set(property.name, property); + } + return { properties: [...merged.values()], description: resolved.description }; +} + +/** The neutral discriminator dispatch table; each language renders its own idiom from it. */ +export function discriminatorCases( + schema: SchemaModel, + model: ApiModel +): + | { property: string; cases: Array<{ value: string; schemaName: string; schema: SchemaModel }> } + | undefined { + const resolved = deref(schema, model); + if (resolved?.kind !== 'union' || resolved.discriminator === undefined) return undefined; + const cases = []; + for (const { value, schemaName } of resolved.discriminator.mapping) { + const target = deref({ kind: 'ref', name: schemaName }, model); + if (target === undefined) return undefined; + cases.push({ value, schemaName, schema: target }); + } + return { property: resolved.discriminator.propertyName, cases }; +} + +export function isNullable(schema: SchemaModel): boolean { + return schema.kind === 'union' && schema.members.some((member) => member.kind === 'null'); +} + +/** The schema without its `null` union members (a single survivor is unwrapped). */ +export function unwrapNullable(schema: SchemaModel): SchemaModel { + if (schema.kind !== 'union' || !isNullable(schema)) return schema; + const rest = schema.members.filter((member) => member.kind !== 'null'); + return rest.length === 1 ? rest[0] : { ...schema, members: rest }; +} + +/** Enum values plus language-safe SCREAMING_SNAKE member-name suggestions. */ +export function enumValues( + schema: SchemaModel +): { values: Array; scalar: string; memberNames: string[] } | undefined { + if (schema.kind !== 'enum') return undefined; + const memberNames = schema.values.map((value) => + typeof value === 'string' ? casing.screaming(value) : `VALUE_${String(value).toUpperCase()}` + ); + return { values: schema.values, scalar: schema.scalar, memberNames }; +} + +/** Description text as trimmed lines ready for any comment syntax; blank edges dropped. */ +export function docText(description?: string): string[] { + if (!description) return []; + const lines = description.split(/\r\n|\n|\r/).map((line) => line.trim()); + while (lines.length > 0 && lines[0] === '') lines.shift(); + while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop(); + return lines; +} From dd3d9ad407b449e5f290df9907b2cd8bf07dad25 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 10:23:27 +0300 Subject: [PATCH 003/211] feat(client-generator): export the language-neutral authoring toolkit from the root and /generate --- .../src/authoring/__tests__/exports.test.ts | 17 +++++++++++ .../client-generator/src/authoring/index.ts | 28 +++++++++++++++++++ .../client-generator/src/authoring/schema.ts | 7 +++-- packages/client-generator/src/generate.ts | 3 ++ packages/client-generator/src/index.ts | 3 ++ 5 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 packages/client-generator/src/authoring/__tests__/exports.test.ts create mode 100644 packages/client-generator/src/authoring/index.ts diff --git a/packages/client-generator/src/authoring/__tests__/exports.test.ts b/packages/client-generator/src/authoring/__tests__/exports.test.ts new file mode 100644 index 0000000000..fbbf68073e --- /dev/null +++ b/packages/client-generator/src/authoring/__tests__/exports.test.ts @@ -0,0 +1,17 @@ +import * as root from '../../index.js'; +import { AUTHORING_HELPER_NAMES } from '../index.js'; + +describe('authoring toolkit exports', () => { + it('exports every helper from the package root (the TS-free entry)', () => { + for (const name of AUTHORING_HELPER_NAMES) { + expect((root as Record)[name], name).toBeDefined(); + } + }); + + it('exports the same helpers from /generate for toolkit-entry consistency', async () => { + const generate = await import('../../generate.js'); + for (const name of AUTHORING_HELPER_NAMES) { + expect((generate as Record)[name], name).toBeDefined(); + } + }); +}); diff --git a/packages/client-generator/src/authoring/index.ts b/packages/client-generator/src/authoring/index.ts new file mode 100644 index 0000000000..e88b9eec39 --- /dev/null +++ b/packages/client-generator/src/authoring/index.ts @@ -0,0 +1,28 @@ +// The language-neutral authoring toolkit barrel. Pure functions over the IR — +// no typescript, no @redocly/openapi-core, no Node builtins — so it is exported +// from the package ROOT: a custom generator importing only these stays TS-free. + +export { CodeWriter } from './code-writer.js'; +export { casing, identifierFor, RESERVED_WORDS } from './naming.js'; +export { + discriminatorCases, + docText, + enumValues, + flattenAllOf, + isNullable, + unwrapNullable, +} from './schema.js'; + +/** Every value exported above — the skill's helper table and Tier-2 telemetry key off this. */ +export const AUTHORING_HELPER_NAMES = [ + 'CodeWriter', + 'casing', + 'identifierFor', + 'RESERVED_WORDS', + 'flattenAllOf', + 'discriminatorCases', + 'isNullable', + 'unwrapNullable', + 'enumValues', + 'docText', +] as const; diff --git a/packages/client-generator/src/authoring/schema.ts b/packages/client-generator/src/authoring/schema.ts index 0bbe1733d6..26eacf1de5 100644 --- a/packages/client-generator/src/authoring/schema.ts +++ b/packages/client-generator/src/authoring/schema.ts @@ -10,9 +10,10 @@ function deref(schema: SchemaModel, model: ApiModel): SchemaModel | undefined { const seen = new Set(); let current = schema; while (current.kind === 'ref') { - if (seen.has(current.name)) return undefined; - seen.add(current.name); - const named = model.schemas.find((s) => s.name === current.name); + const { name } = current; + if (seen.has(name)) return undefined; + seen.add(name); + const named = model.schemas.find((s) => s.name === name); if (named === undefined) return undefined; current = named.schema; } diff --git a/packages/client-generator/src/generate.ts b/packages/client-generator/src/generate.ts index deb9a66bee..2bcaff230c 100644 --- a/packages/client-generator/src/generate.ts +++ b/packages/client-generator/src/generate.ts @@ -31,6 +31,9 @@ export { printStatements, ts, } from './emitters/ts.js'; +// The language-neutral authoring helpers, re-exported here so both toolkit +// entries offer the full authoring surface (the root offers them TS-free). +export * from './authoring/index.js'; export { operationSignature } from './emitters/operation-signature.js'; export type { OperationSignature } from './emitters/operation-signature.js'; export { schemaToTypeNode } from './emitters/types.js'; diff --git a/packages/client-generator/src/index.ts b/packages/client-generator/src/index.ts index 848a0e4d55..a64ca169ac 100644 --- a/packages/client-generator/src/index.ts +++ b/packages/client-generator/src/index.ts @@ -3,6 +3,9 @@ // builtins; guarded by entry-weight.test.ts). The generation stack lives behind the dynamic // import inside `generateClient` and the `@redocly/client-generator/generate` entry. +// The language-neutral generator-authoring toolkit — pure functions over the IR, +// safe on this runtime-only entry (no typescript, no openapi-core, no builtins). +export * from './authoring/index.js'; export { NotSupportedError } from './errors.js'; export { defineClientSetup } from './runtime-contract.js'; export type { From ab4fd930f513f85a98f6afc6013505827632bacb Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 10:37:47 +0300 Subject: [PATCH 004/211] refactor(client-generator): TS-free generation pipeline with lazily loaded built-in generators --- .../scripts/generate-runtime-sources.mjs | 35 +++++ .../src/__tests__/pipeline-ts-free.test.ts | 60 +++++++ .../src/emitters/reserved-names.ts | 41 ++--- .../src/emitters/runtime-sources.ts | 80 ++++++++++ packages/client-generator/src/generate.ts | 124 +-------------- .../client-generator/src/generators/index.ts | 74 +++------ .../client-generator/src/generators/meta.ts | 98 ++++++++++++ .../src/generators/resolve.ts | 15 +- packages/client-generator/src/index.ts | 4 +- packages/client-generator/src/pipeline.ts | 146 ++++++++++++++++++ 10 files changed, 475 insertions(+), 202 deletions(-) create mode 100644 packages/client-generator/src/__tests__/pipeline-ts-free.test.ts create mode 100644 packages/client-generator/src/generators/meta.ts create mode 100644 packages/client-generator/src/pipeline.ts diff --git a/packages/client-generator/scripts/generate-runtime-sources.mjs b/packages/client-generator/scripts/generate-runtime-sources.mjs index 4516820f20..2060c9ea1e 100644 --- a/packages/client-generator/scripts/generate-runtime-sources.mjs +++ b/packages/client-generator/scripts/generate-runtime-sources.mjs @@ -1,6 +1,7 @@ import { readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; // Snapshot src/runtime/*.ts source text into a tracked TS module so the inline assembler // can embed the real runtime (a readFileSync asset would not survive the CLI's esbuild @@ -45,6 +46,34 @@ const entries = MODULES.map((name) => { return line.length <= 100 ? line : ` '${name}.ts':\n ${toStringLiteral(source)},`; }); +// Top-level declared names of every runtime module, precomputed here (with the TS +// parser, a devDependency) so the runtime-agnostic pipeline never needs `typescript` +// to build the reserved-name set. Mirrors collectDeclaredName's rules. +function declaredNames() { + const names = new Set(); + for (const name of MODULES) { + const source = readFileSync(join(runtimeDir, `${name}.ts`), 'utf-8'); + const file = ts.createSourceFile(`${name}.ts`, source, ts.ScriptTarget.Latest, false); + for (const statement of file.statements) { + if ( + (ts.isFunctionDeclaration(statement) || + ts.isClassDeclaration(statement) || + ts.isInterfaceDeclaration(statement) || + ts.isTypeAliasDeclaration(statement) || + ts.isEnumDeclaration(statement)) && + statement.name !== undefined + ) { + names.add(statement.name.text); + } else if (ts.isVariableStatement(statement)) { + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name)) names.add(declaration.name.text); + } + } + } + } + return [...names].sort(); +} + const content = [ '// GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`.', 'export const RUNTIME_SOURCES = {', @@ -53,6 +82,12 @@ const content = [ '', 'export type RuntimeModuleName = keyof typeof RUNTIME_SOURCES;', '', + '/** Top-level declared names of the runtime modules — precomputed so the pipeline', + ' * builds the reserved-name set without the TypeScript parser. */', + 'export const RUNTIME_DECLARED_NAMES = [', + ...declaredNames().map((name) => ` '${name}',`), + '] as const;', + '', ].join('\n'); writeFileSync(outFile, content); diff --git a/packages/client-generator/src/__tests__/pipeline-ts-free.test.ts b/packages/client-generator/src/__tests__/pipeline-ts-free.test.ts new file mode 100644 index 0000000000..02ab77f303 --- /dev/null +++ b/packages/client-generator/src/__tests__/pipeline-ts-free.test.ts @@ -0,0 +1,60 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// The pipeline (loadSpec → IR → resolve → run) must not load `typescript` +// unless a TS-emitting generator is actually selected. Built-ins are reached +// only through dynamic imports in generators/meta.js, which this static walk +// deliberately does not follow — so any static leak fails here. +const libDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../lib'); + +const STATIC_IMPORT = /(?:^|\n)(?:import|export)\s[^'"]*?from\s+['"]([^'"]+)['"]/g; + +function staticGraph(entry: string): { files: Set; externals: Set } { + const files = new Set(); + const externals = new Set(); + const queue = [entry]; + while (queue.length > 0) { + const file = queue.pop()!; + if (files.has(file)) continue; + files.add(file); + const source = readFileSync(file, 'utf-8'); + for (const match of source.matchAll(STATIC_IMPORT)) { + const specifier = match[1]; + if (specifier.startsWith('.')) { + queue.push(join(dirname(file), specifier)); + } else { + externals.add( + specifier + .split('/') + .slice(0, specifier.startsWith('@') ? 2 : 1) + .join('/') + ); + } + } + } + return { files, externals }; +} + +// Emitter modules the IR legitimately shares (identifier/name sanitizing) — all +// pure string/data helpers with no `typescript` import. Anything else from +// emitters/ appearing in the pipeline graph is a leak. +const PURE_EMITTER_HELPERS = new Set([ + 'auth.js', + 'identifier.js', + 'reserved-names.js', + 'runtime-sources.js', + 'support.js', +]); + +describe('pipeline (lib/pipeline.js)', () => { + it('statically loads no typescript and only the pure emitter helpers', () => { + const { files, externals } = staticGraph(join(libDir, 'pipeline.js')); + expect(externals.has('typescript')).toBe(false); + const emitterFiles = [...files] + .filter((file) => /\/emitters\//.test(file)) + .map((file) => file.split('/emitters/')[1]) + .filter((name) => !PURE_EMITTER_HELPERS.has(name)); + expect(emitterFiles).toEqual([]); + }); +}); diff --git a/packages/client-generator/src/emitters/reserved-names.ts b/packages/client-generator/src/emitters/reserved-names.ts index d2d6f9a983..e1971d255c 100644 --- a/packages/client-generator/src/emitters/reserved-names.ts +++ b/packages/client-generator/src/emitters/reserved-names.ts @@ -4,12 +4,11 @@ // import or declare, the platform globals the emitted code references bare (a // same-named schema TYPE would shadow them), and every top-level declaration of the // runtime sources (in embed mode ALL of them — even module-local helpers — share -// the module scope with the generated code). The runtime layer is parsed from -// `RUNTIME_SOURCES`, so it tracks the real runtime with no hand-maintained list to -// drift. +// the module scope with the generated code). The runtime layer is precomputed from +// the runtime sources at prepare time (`RUNTIME_DECLARED_NAMES`), so it tracks the +// real runtime with no hand-maintained list to drift. -import { RUNTIME_SOURCES } from './runtime-sources.js'; -import { parseStatements, ts } from './ts.js'; +import { RUNTIME_DECLARED_NAMES } from './runtime-sources.js'; /** Module-scope identifiers every package-mode sdk file emits or imports — never renamed. */ export const WIRING_NAMES = [ @@ -116,30 +115,18 @@ const GLOBAL_NAMES = [ let cached: Set | undefined; -/** Every name the generated modules reserve: wiring + satellite + globals + runtime declarations. */ +/** Every name the generated modules reserve: wiring + satellite + globals + runtime + * declarations. The runtime layer is precomputed at prepare time + * (`RUNTIME_DECLARED_NAMES`), so building this set never needs the TS parser — + * keeping the pipeline `typescript`-free for non-TS generator selections. */ export function reservedModuleNames(): Set { if (cached === undefined) { - cached = new Set([...WIRING_NAMES, ...SATELLITE_NAMES, ...GLOBAL_NAMES]); - for (const source of Object.values(RUNTIME_SOURCES)) { - for (const statement of parseStatements(source)) collectDeclaredName(statement, cached); - } + cached = new Set([ + ...WIRING_NAMES, + ...SATELLITE_NAMES, + ...GLOBAL_NAMES, + ...RUNTIME_DECLARED_NAMES, + ]); } return cached; } - -function collectDeclaredName(statement: ts.Statement, into: Set): void { - if ( - (ts.isFunctionDeclaration(statement) || - ts.isClassDeclaration(statement) || - ts.isInterfaceDeclaration(statement) || - ts.isTypeAliasDeclaration(statement) || - ts.isEnumDeclaration(statement)) && - statement.name !== undefined - ) { - into.add(statement.name.text); - } else if (ts.isVariableStatement(statement)) { - for (const declaration of statement.declarationList.declarations) { - if (ts.isIdentifier(declaration.name)) into.add(declaration.name.text); - } - } -} diff --git a/packages/client-generator/src/emitters/runtime-sources.ts b/packages/client-generator/src/emitters/runtime-sources.ts index 2c8d3e0c4a..4fb9da9190 100644 --- a/packages/client-generator/src/emitters/runtime-sources.ts +++ b/packages/client-generator/src/emitters/runtime-sources.ts @@ -27,3 +27,83 @@ export const RUNTIME_SOURCES = { } as const; export type RuntimeModuleName = keyof typeof RUNTIME_SOURCES; + +/** Top-level declared names of the runtime modules — precomputed so the pipeline + * builds the reserved-name set without the TypeScript parser. */ +export const RUNTIME_DECLARED_NAMES = [ + 'ApiError', + 'ApiErrorLike', + 'AuthCredentials', + 'Capabilities', + 'Client', + 'ClientConfig', + 'ClientCore', + 'FRAME_DELIMITER', + 'IDEMPOTENT_METHODS', + 'LinkPageCall', + 'Middleware', + 'NoRequiredKeys', + 'OperationArgs', + 'OperationContext', + 'OperationDescriptor', + 'OperationMethodIdentity', + 'OpsShape', + 'PageOf', + 'Paginated', + 'PaginationSpec', + 'ParamSpec', + 'ParseAs', + 'QueryStyle', + 'QueryValue', + 'RequestContext', + 'RequestOptions', + 'Result', + 'RetryConfig', + 'RetryContext', + 'RetryStrategy', + 'SecuritySpec', + 'SendCapabilities', + 'ServerSentEvent', + 'SseOptions', + 'SseParseError', + 'TRANSIENT_STATUS', + 'TimeoutError', + 'TokenProvider', + 'abortError', + 'acceptFor', + 'buildUrl', + 'createClientCore', + 'defaultRetryOn', + 'encodeBase64', + 'encodeReserved', + 'execute', + 'isConfigured', + 'items', + 'itemsByLink', + 'kindFor', + 'linkNext', + 'linkPageCall', + 'mergeSetup', + 'middlewareChain', + 'pageCall', + 'pages', + 'pagesByLink', + 'paginateCapability', + 'parse', + 'parseSseFrame', + 'prepareRequest', + 'queryStyles', + 'readError', + 'resolveAuth', + 'resolvePointer', + 'resolveToken', + 'retryDelay', + 'send', + 'sleep', + 'splitArgs', + 'sse', + 'stringHeaders', + 'substitutePath', + 'toFormData', + 'toHeaderRecord', +] as const; diff --git a/packages/client-generator/src/generate.ts b/packages/client-generator/src/generate.ts index 2bcaff230c..8cca403333 100644 --- a/packages/client-generator/src/generate.ts +++ b/packages/client-generator/src/generate.ts @@ -1,24 +1,14 @@ -// The generate entry (`@redocly/client-generator/generate`): everything that runs at -// GENERATION time — `generateClient`, `collectGeneratedFiles`, and the TypeScript-emitting -// toolkit for custom generators. It loads `typescript` and `@redocly/openapi-core`, so it -// must never be reached statically from the package root: package-mode clients import the -// root at app runtime, and the root reaches this module only through the dynamic import -// inside its `generateClient` facade. - -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { dirname, resolve } from 'node:path'; +// The generate entry (`@redocly/client-generator/generate`): the TypeScript-emitting +// toolkit for custom generators plus `collectGeneratedFiles` and a `generateClient` +// re-export. It loads `typescript` and `@redocly/openapi-core`, so it must never be +// reached statically from the package root: package-mode clients import the root at +// app runtime, and the root reaches the pipeline only through a dynamic import. import type { EmitOptions } from './emitters/emit-options.js'; -import { bakeSetup } from './emitters/setup-bake.js'; -import { NotSupportedError } from './errors.js'; import { builtinGenerators, validateGenerators } from './generators/index.js'; -import { resolveGenerators } from './generators/resolve.js'; import type { GeneratedFile, GeneratorDescriptor, OutputMode } from './generators/types.js'; -import { buildApiModel } from './intermediate-representation/build.js'; import type { ApiModel } from './intermediate-representation/model.js'; -import { normalizeSwagger2 } from './intermediate-representation/normalize-swagger2.js'; -import { loadSpec } from './loader.js'; -import type { GenerateClientOptions, GenerateClientResult } from './types.js'; +import { runGenerators } from './pipeline.js'; // --- Codegen toolkit: build TypeScript the same way the built-in generators do ----------------- export { @@ -60,105 +50,7 @@ export function collectGeneratedFiles( // Fail fast on an incompatible selection (missing prerequisite, unsupported // error-mode/date-type/runtime) before producing any file. validateGenerators(options.generators, options.emit, registry); - const files: GeneratedFile[] = []; - const seen = new Set(); - for (const name of options.generators) { - const generator = registry.get(name)!; - for (const file of generator.run({ - model, - outputPath: options.outputPath, - outputMode: options.outputMode, - emit: options.emit, - })) { - if (seen.has(file.path)) { - throw new Error(`Generator conflict: ${file.path} already emitted by an earlier generator`); - } - seen.add(file.path); - files.push(file); - } - } - return files; + return runGenerators(model, { ...options, registry }); } -export async function generateClient( - options: GenerateClientOptions -): Promise { - // A path segment that is literally "undefined"/"null" is the telltale of an - // interpolation bug in the caller (`\`${dir}/client.ts\`` with `dir` unset) — reject - // it instead of silently creating an `undefined/` directory. - if ( - options.output.split(/[\\/]/).some((segment) => segment === 'undefined' || segment === 'null') - ) { - throw new Error( - `output path "${options.output}" contains a literal "undefined" or "null" segment — this looks like an interpolation bug in the caller` - ); - } - // Setup is a LOCAL module (its code is baked into the generated client) — reject - // URL-ish specifiers upfront, before any spec loading, instead of failing later as - // an unreadable file path. Two+ letter scheme, so Windows drive paths don't match. - if (options.setup && /^[a-z][a-z0-9+.-]+:/i.test(options.setup)) { - throw new NotSupportedError( - `setup must be a local file path — remote setup modules are not supported (got: ${options.setup})` - ); - } - const outputPath = resolve(options.output); - const { document, version } = await loadSpec(options.api, options.config); - const normalized = - version === 'oas2' - ? normalizeSwagger2(document as unknown as Record) - : document; - const model = buildApiModel(normalized); - - // A publisher `--setup` module is read, validated, and transformed into the neutral setup - // expression baked into the client. Applied across all output modes by the emitter. - let setupBlock: string | undefined; - if (options.setup) { - // A relative setup path resolves against `configDir` (cwd when absent), like - // generator specifiers. The CLI pre-resolves its inputs, so they arrive absolute. - const setupPath = resolve(options.configDir ?? process.cwd(), options.setup); - setupBlock = bakeSetup(await readFile(setupPath, 'utf-8')); - } - - // Resolve the selection into a registry: built-in names pass through, inline `customGenerators` - // register, and any other entry is imported as a plugin specifier (path/package). - // An empty list (e.g. `generators: []` in config, or no `--generator` flags) means - // "unspecified" — fall back to the default sdk client rather than emitting nothing. - const requested = options.generators?.length ? options.generators : ['sdk']; - const { selected, registry } = await resolveGenerators(requested, { - customGenerators: options.customGenerators, - configDir: options.configDir, - }); - - const files = collectGeneratedFiles(model, { - outputPath, - outputMode: options.outputMode ?? 'single', - emit: { - serverUrl: options.serverUrl, - argsStyle: options.argsStyle, - errorMode: options.errorMode, - dateType: options.dateType, - mockData: options.mockData, - mockSeed: options.mockSeed, - queryKeyPrefix: options.queryKeyPrefix, - setup: setupBlock, - runtime: options.runtime, - importExt: options.importExt, - pagination: options.pagination, - }, - generators: selected, - registry, - }); - - const written: GenerateClientResult['files'] = []; - for (const file of files) { - await mkdir(dirname(file.path), { recursive: true }); - await writeFile(file.path, file.content, 'utf-8'); - written.push({ path: file.path, bytes: Buffer.byteLength(file.content, 'utf-8') }); - } - - return { - outputPath, - bytes: written.reduce((sum, file) => sum + file.bytes, 0), - files: written, - }; -} +export { generateClient } from './pipeline.js'; diff --git a/packages/client-generator/src/generators/index.ts b/packages/client-generator/src/generators/index.ts index 7613eac5be..3d2dc51670 100644 --- a/packages/client-generator/src/generators/index.ts +++ b/packages/client-generator/src/generators/index.ts @@ -1,5 +1,5 @@ import type { EmitOptions } from '../emitters/emit-options.js'; -import { NotSupportedError } from '../errors.js'; +import { BUILTIN_META, validateSelection, type BuiltinMeta } from './meta.js'; import { mockGenerator } from './mock.js'; import { sdkGenerator } from './sdk.js'; import { swrGenerator } from './swr.js'; @@ -16,30 +16,28 @@ export type { GeneratorName, } from './types.js'; -function tanstackQuery(framework: 'react' | 'vue' | 'svelte' | 'solid'): GeneratorDescriptor { - return { run: tanstackQueryGenerator(framework), requires: ['sdk'], errorModes: ['throw'] }; -} - -const GENERATORS: Record = { - // sdk is the base client; zod emits a standalone schema module importing nothing from it. +// The sync registry for the `/generate` toolkit entry (which loads the emitters +// statically anyway). Compatibility metadata lives in BUILTIN_META — one home; +// only the eagerly imported `run` functions live here. The pipeline entry never +// touches this module: it loads built-ins lazily through the meta table. +const RUNS: Record> = { sdk: { run: sdkGenerator }, zod: { run: zodGenerator }, - // transformers import the schema *types* from the sdk entry module (so sdk must run) and - // assign `Date` values to those fields, which only type-checks when the sdk types dates as `Date`. - transformers: { run: transformersGenerator, requires: ['sdk'], dateTypes: ['Date'] }, - // tanstack-query wraps the sdk's exported, throw-mode operation functions — present in - // both runtime distributions, so no runtime restriction. The framework variants differ - // only in the `@tanstack/-query` import; the bare name means React. - 'tanstack-query': tanstackQuery('react'), - 'tanstack-query-vue': tanstackQuery('vue'), - 'tanstack-query-svelte': tanstackQuery('svelte'), - 'tanstack-query-solid': tanstackQuery('solid'), - // swr wraps the sdk's exported, throw-mode operation functions as SWR hooks. - swr: { run: swrGenerator, requires: ['sdk'], errorModes: ['throw'] }, - // mock emits a standalone MSW handlers/factories module referencing the sdk's types. - mock: { run: mockGenerator, requires: ['sdk'] }, + transformers: { run: transformersGenerator }, + 'tanstack-query': { run: tanstackQueryGenerator('react') }, + 'tanstack-query-vue': { run: tanstackQueryGenerator('vue') }, + 'tanstack-query-svelte': { run: tanstackQueryGenerator('svelte') }, + 'tanstack-query-solid': { run: tanstackQueryGenerator('solid') }, + swr: { run: swrGenerator }, + mock: { run: mockGenerator }, }; +const GENERATORS = Object.fromEntries( + (Object.entries(BUILTIN_META) as [GeneratorName, BuiltinMeta][]).map( + ([name, { load: _load, ...meta }]) => [name, { ...meta, ...RUNS[name] }] + ) +) as Record; + /** * A fresh registry of the built-in generators keyed by name. The plugin resolver seeds from this * and adds custom generators to the copy, so mutating the result never affects the built-in table. @@ -58,37 +56,5 @@ export function validateGenerators( emit: EmitOptions, registry: Map = builtinGenerators() ): void { - const selected = new Set(names); - const errorMode = emit.errorMode ?? 'throw'; - const dateType = emit.dateType ?? 'string'; - const runtime = emit.runtime ?? 'inline'; - for (const name of names) { - const descriptor = registry.get(name); - if (!descriptor) { - throw new NotSupportedError(`Unknown generator: ${name}`); - } - for (const required of descriptor.requires ?? []) { - if (!selected.has(required)) { - const fixed = [...new Set([required, ...names])].map((g) => `--generator ${g}`).join(' '); - throw new NotSupportedError( - `The "${name}" generator requires the "${required}" generator. Add it, e.g. ${fixed}.` - ); - } - } - if (descriptor.errorModes && !descriptor.errorModes.includes(errorMode)) { - throw new NotSupportedError( - `The "${name}" generator does not support --error-mode "${errorMode}" (supported: ${descriptor.errorModes.join(', ')}).` - ); - } - if (descriptor.dateTypes && !descriptor.dateTypes.includes(dateType)) { - throw new NotSupportedError( - `The "${name}" generator requires --date-type ${descriptor.dateTypes.join(' or ')} (got "${dateType}") so the runtime values match the generated types.` - ); - } - if (descriptor.runtimes && !descriptor.runtimes.includes(runtime)) { - throw new NotSupportedError( - `The "${name}" generator does not support runtime "${runtime}" (supported: ${descriptor.runtimes.join(', ')}).` - ); - } - } + validateSelection(names, emit, registry); } diff --git a/packages/client-generator/src/generators/meta.ts b/packages/client-generator/src/generators/meta.ts new file mode 100644 index 0000000000..53887141b5 --- /dev/null +++ b/packages/client-generator/src/generators/meta.ts @@ -0,0 +1,98 @@ +// Built-in generator METADATA — importable without loading any emitter (and so +// without loading `typescript`). The pipeline validates selections against this +// table and dynamic-imports only the generators actually selected; the sync +// `/generate` registry in index.ts derives from it, so the metadata has one home. + +import type { EmitOptions } from '../emitters/emit-options.js'; +import { NotSupportedError } from '../errors.js'; +import type { GeneratorDescriptor, GeneratorName } from './types.js'; + +export type BuiltinMeta = Omit & { + load: () => Promise>; +}; + +function tanstackQuery(framework: 'react' | 'vue' | 'svelte' | 'solid'): BuiltinMeta { + return { + requires: ['sdk'], + errorModes: ['throw'], + load: () => + import('./tanstack-query.js').then((m) => ({ run: m.tanstackQueryGenerator(framework) })), + }; +} + +export const BUILTIN_META: Record = { + // sdk is the base client; zod emits a standalone schema module importing nothing from it. + sdk: { load: () => import('./sdk.js').then((m) => ({ run: m.sdkGenerator })) }, + zod: { load: () => import('./zod.js').then((m) => ({ run: m.zodGenerator })) }, + // transformers import the schema *types* from the sdk entry module (so sdk must run) and + // assign `Date` values to those fields, which only type-checks when the sdk types dates as `Date`. + transformers: { + requires: ['sdk'], + dateTypes: ['Date'], + load: () => import('./transformers.js').then((m) => ({ run: m.transformersGenerator })), + }, + // tanstack-query wraps the sdk's exported, throw-mode operation functions — present in + // both runtime distributions, so no runtime restriction. The framework variants differ + // only in the `@tanstack/-query` import; the bare name means React. + 'tanstack-query': tanstackQuery('react'), + 'tanstack-query-vue': tanstackQuery('vue'), + 'tanstack-query-svelte': tanstackQuery('svelte'), + 'tanstack-query-solid': tanstackQuery('solid'), + // swr wraps the sdk's exported, throw-mode operation functions as SWR hooks. + swr: { + requires: ['sdk'], + errorModes: ['throw'], + load: () => import('./swr.js').then((m) => ({ run: m.swrGenerator })), + }, + // mock emits a standalone MSW handlers/factories module referencing the sdk's types. + mock: { + requires: ['sdk'], + load: () => import('./mock.js').then((m) => ({ run: m.mockGenerator })), + }, +}; + +/** + * Validate a generator selection against every selected generator's declared + * contract, throwing the first violation with an actionable message. Runs before + * any file is produced so an incompatible combination never reaches the printer. + * Works on metadata alone — the `run` field is never touched. + */ +export function validateSelection( + names: string[], + emit: EmitOptions, + registry: Map | GeneratorDescriptor> +): void { + const selected = new Set(names); + const errorMode = emit.errorMode ?? 'throw'; + const dateType = emit.dateType ?? 'string'; + const runtime = emit.runtime ?? 'inline'; + for (const name of names) { + const descriptor = registry.get(name); + if (!descriptor) { + throw new NotSupportedError(`Unknown generator: ${name}`); + } + for (const required of descriptor.requires ?? []) { + if (!selected.has(required)) { + const fixed = [...new Set([required, ...names])].map((g) => `--generator ${g}`).join(' '); + throw new NotSupportedError( + `The "${name}" generator requires the "${required}" generator. Add it, e.g. ${fixed}.` + ); + } + } + if (descriptor.errorModes && !descriptor.errorModes.includes(errorMode)) { + throw new NotSupportedError( + `The "${name}" generator does not support --error-mode "${errorMode}" (supported: ${descriptor.errorModes.join(', ')}).` + ); + } + if (descriptor.dateTypes && !descriptor.dateTypes.includes(dateType)) { + throw new NotSupportedError( + `The "${name}" generator requires --date-type ${descriptor.dateTypes.join(' or ')} (got "${dateType}") so the runtime values match the generated types.` + ); + } + if (descriptor.runtimes && !descriptor.runtimes.includes(runtime)) { + throw new NotSupportedError( + `The "${name}" generator does not support runtime "${runtime}" (supported: ${descriptor.runtimes.join(', ')}).` + ); + } + } +} diff --git a/packages/client-generator/src/generators/resolve.ts b/packages/client-generator/src/generators/resolve.ts index 509fe72514..d94e8de9ce 100644 --- a/packages/client-generator/src/generators/resolve.ts +++ b/packages/client-generator/src/generators/resolve.ts @@ -10,7 +10,7 @@ import { isAbsolute, resolve as resolvePath } from 'node:path'; import { pathToFileURL } from 'node:url'; import { NotSupportedError } from '../errors.js'; -import { builtinGenerators } from './index.js'; +import { BUILTIN_META, type BuiltinMeta } from './meta.js'; import type { CustomGenerator, GeneratorDescriptor } from './types.js'; export type ResolvedGenerators = { @@ -35,7 +35,9 @@ export async function resolveGenerators( entries: string[], options: ResolveOptions = {} ): Promise { - const registry = builtinGenerators(); + // Built-ins are loaded lazily through BUILTIN_META so a selection without a + // TypeScript-emitting generator never loads the `typescript` package. + const registry = new Map(); for (const custom of options.customGenerators ?? []) register(registry, custom); const selected: string[] = []; @@ -44,6 +46,13 @@ export async function resolveGenerators( selected.push(entry); continue; } + const meta = (BUILTIN_META as Record)[entry]; + if (meta !== undefined) { + const { load, ...compatibility } = meta; + registry.set(entry, { ...compatibility, ...(await load()) }); + selected.push(entry); + continue; + } const custom = await importGenerator(entry, options.configDir ?? process.cwd()); register(registry, custom); selected.push(custom.name); @@ -63,7 +72,7 @@ function register(registry: Map, custom: CustomGene 'Invalid custom generator: expected an object with a non-empty string `name` and a `run` function (build one with `defineGenerator`).' ); } - if (registry.has(custom.name)) { + if (registry.has(custom.name) || custom.name in BUILTIN_META) { throw new NotSupportedError( `Generator name "${custom.name}" collides with an existing generator. Rename the custom generator.` ); diff --git a/packages/client-generator/src/index.ts b/packages/client-generator/src/index.ts index a64ca169ac..6dee0fc702 100644 --- a/packages/client-generator/src/index.ts +++ b/packages/client-generator/src/index.ts @@ -68,6 +68,6 @@ import type { GenerateClientOptions, GenerateClientResult } from './types.js'; export async function generateClient( options: GenerateClientOptions ): Promise { - const generate = await import('./generate.js'); - return generate.generateClient(options); + const pipeline = await import('./pipeline.js'); + return pipeline.generateClient(options); } diff --git a/packages/client-generator/src/pipeline.ts b/packages/client-generator/src/pipeline.ts new file mode 100644 index 0000000000..5d620050a8 --- /dev/null +++ b/packages/client-generator/src/pipeline.ts @@ -0,0 +1,146 @@ +// The generation pipeline: loadSpec → IR → resolve generators → run → write. +// This module must stay free of static `typescript` imports (pinned by +// pipeline-ts-free.test.ts): built-in generators load lazily through +// generators/meta.js, and the TS-specific setup baking loads on demand — so a +// run selecting only non-TypeScript generators never loads the `typescript` +// package. The `/generate` entry re-exports `generateClient` from here and +// layers the sync TS toolkit on top. + +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; + +import type { EmitOptions } from './emitters/emit-options.js'; +import { NotSupportedError } from './errors.js'; +import { validateSelection } from './generators/meta.js'; +import { resolveGenerators } from './generators/resolve.js'; +import type { GeneratedFile, GeneratorDescriptor, OutputMode } from './generators/types.js'; +import { buildApiModel } from './intermediate-representation/build.js'; +import type { ApiModel } from './intermediate-representation/model.js'; +import { normalizeSwagger2 } from './intermediate-representation/normalize-swagger2.js'; +import { loadSpec } from './loader.js'; +import type { GenerateClientOptions, GenerateClientResult } from './types.js'; + +/** + * Run each generator of a fully-loaded registry against the IR and concatenate + * their files. Throws on a duplicate output path so two generators can't + * silently clobber each other. Validation is the caller's job (`validateSelection`). + */ +export function runGenerators( + model: ApiModel, + options: { + outputPath: string; + outputMode: OutputMode; + emit: EmitOptions; + generators: string[]; + registry: Map; + } +): GeneratedFile[] { + const files: GeneratedFile[] = []; + const seen = new Set(); + for (const name of options.generators) { + const generator = options.registry.get(name)!; + for (const file of generator.run({ + model, + outputPath: options.outputPath, + outputMode: options.outputMode, + emit: options.emit, + })) { + if (seen.has(file.path)) { + throw new Error(`Generator conflict: ${file.path} already emitted by an earlier generator`); + } + seen.add(file.path); + files.push(file); + } + } + return files; +} + +export async function generateClient( + options: GenerateClientOptions +): Promise { + // A path segment that is literally "undefined"/"null" is the telltale of an + // interpolation bug in the caller (`\`${dir}/client.ts\`` with `dir` unset) — reject + // it instead of silently creating an `undefined/` directory. + if ( + options.output.split(/[\\/]/).some((segment) => segment === 'undefined' || segment === 'null') + ) { + throw new Error( + `output path "${options.output}" contains a literal "undefined" or "null" segment — this looks like an interpolation bug in the caller` + ); + } + // Setup is a LOCAL module (its code is baked into the generated client) — reject + // URL-ish specifiers upfront, before any spec loading, instead of failing later as + // an unreadable file path. Two+ letter scheme, so Windows drive paths don't match. + if (options.setup && /^[a-z][a-z0-9+.-]+:/i.test(options.setup)) { + throw new NotSupportedError( + `setup must be a local file path — remote setup modules are not supported (got: ${options.setup})` + ); + } + const outputPath = resolve(options.output); + const { document, version } = await loadSpec(options.api, options.config); + const normalized = + version === 'oas2' + ? normalizeSwagger2(document as unknown as Record) + : document; + const model = buildApiModel(normalized); + + // A publisher `--setup` module is read, validated, and transformed into the neutral setup + // expression baked into the client. Applied across all output modes by the emitter. + // Baking parses TypeScript, so the module loads only when setup is actually used. + let setupBlock: string | undefined; + if (options.setup) { + const { bakeSetup } = await import('./emitters/setup-bake.js'); + // A relative setup path resolves against `configDir` (cwd when absent), like + // generator specifiers. The CLI pre-resolves its inputs, so they arrive absolute. + const setupPath = resolve(options.configDir ?? process.cwd(), options.setup); + setupBlock = bakeSetup(await readFile(setupPath, 'utf-8')); + } + + // Resolve the selection into a registry: built-in names load lazily, inline + // `customGenerators` register, and any other entry is imported as a plugin + // specifier (path/package). An empty list (e.g. `generators: []` in config, or + // no `--generator` flags) means "unspecified" — fall back to the default sdk + // client rather than emitting nothing. + const requested = options.generators?.length ? options.generators : ['sdk']; + const { selected, registry } = await resolveGenerators(requested, { + customGenerators: options.customGenerators, + configDir: options.configDir, + }); + + const emit: EmitOptions = { + serverUrl: options.serverUrl, + argsStyle: options.argsStyle, + errorMode: options.errorMode, + dateType: options.dateType, + mockData: options.mockData, + mockSeed: options.mockSeed, + queryKeyPrefix: options.queryKeyPrefix, + setup: setupBlock, + runtime: options.runtime, + importExt: options.importExt, + pagination: options.pagination, + }; + // Fail fast on an incompatible selection (missing prerequisite, unsupported + // error-mode/date-type/runtime) before producing any file. + validateSelection(selected, emit, registry); + const files = runGenerators(model, { + outputPath, + outputMode: options.outputMode ?? 'single', + emit, + generators: selected, + registry, + }); + + const written: GenerateClientResult['files'] = []; + for (const file of files) { + await mkdir(dirname(file.path), { recursive: true }); + await writeFile(file.path, file.content, 'utf-8'); + written.push({ path: file.path, bytes: Buffer.byteLength(file.content, 'utf-8') }); + } + + return { + outputPath, + bytes: written.reduce((sum, file) => sum + file.bytes, 0), + files: written, + }; +} From ab6b02669cefce7b5864b1fae89aea840839f194 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 10:42:44 +0300 Subject: [PATCH 005/211] feat(client-generator): x-codeSamples overlay from generator sample() hooks, sdk as reference implementation --- .../src/__tests__/code-samples.test.ts | 66 +++++++++++++++++++ .../client-generator/src/generators/index.ts | 6 +- .../client-generator/src/generators/meta.ts | 8 ++- .../client-generator/src/generators/sdk.ts | 33 +++++++++- .../client-generator/src/generators/types.ts | 11 +++- packages/client-generator/src/pipeline.ts | 49 +++++++++++++- packages/client-generator/src/types.ts | 6 ++ packages/core/src/types/redocly-yaml.ts | 1 + .../generate-client/redocly-config.test.ts | 20 ++++++ 9 files changed, 190 insertions(+), 10 deletions(-) create mode 100644 packages/client-generator/src/__tests__/code-samples.test.ts diff --git a/packages/client-generator/src/__tests__/code-samples.test.ts b/packages/client-generator/src/__tests__/code-samples.test.ts new file mode 100644 index 0000000000..b2e39bc107 --- /dev/null +++ b/packages/client-generator/src/__tests__/code-samples.test.ts @@ -0,0 +1,66 @@ +import { parseYaml } from '@redocly/openapi-core'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { outdent } from 'outdent'; + +import { generateClient } from '../index.js'; + +const SPEC = outdent` + openapi: 3.1.0 + info: { title: t, version: '1' } + servers: [{ url: https://api.example.com }] + paths: + /pets: + get: + operationId: listPets + responses: + '200': + description: ok + content: + application/json: + schema: + type: object + properties: + items: { type: array, items: { type: string } } +`; + +type Overlay = { + overlay: string; + actions: Array<{ target: string; update: Record }>; +}; + +describe('codeSamples', () => { + it('emits an OpenAPI Overlay of x-codeSamples collected from generators that implement sample()', async () => { + const dir = await mkdtemp(join(tmpdir(), 'code-samples-')); + try { + await writeFile(join(dir, 'openapi.yaml'), SPEC); + await generateClient({ + api: join(dir, 'openapi.yaml'), + output: join(dir, 'client.ts'), + codeSamples: true, + }); + const overlay = parseYaml( + await readFile(join(dir, 'client.code-samples.yaml'), 'utf-8') + ) as Overlay; + expect(overlay.overlay).toBe('1.0.0'); + const action = overlay.actions.find((a) => a.target === "$.paths['/pets'].get")!; + const samples = action.update['x-codeSamples'] as Array>; + expect(samples[0]).toMatchObject({ lang: 'typescript' }); + expect(samples[0].source).toContain('listPets'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('emits no overlay file when codeSamples is off', async () => { + const dir = await mkdtemp(join(tmpdir(), 'code-samples-off-')); + try { + await writeFile(join(dir, 'openapi.yaml'), SPEC); + await generateClient({ api: join(dir, 'openapi.yaml'), output: join(dir, 'client.ts') }); + await expect(readFile(join(dir, 'client.code-samples.yaml'), 'utf-8')).rejects.toThrow(); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/client-generator/src/generators/index.ts b/packages/client-generator/src/generators/index.ts index 3d2dc51670..26f18249e6 100644 --- a/packages/client-generator/src/generators/index.ts +++ b/packages/client-generator/src/generators/index.ts @@ -1,7 +1,7 @@ import type { EmitOptions } from '../emitters/emit-options.js'; import { BUILTIN_META, validateSelection, type BuiltinMeta } from './meta.js'; import { mockGenerator } from './mock.js'; -import { sdkGenerator } from './sdk.js'; +import { sdkGenerator, sdkSample } from './sdk.js'; import { swrGenerator } from './swr.js'; import { tanstackQueryGenerator } from './tanstack-query.js'; import { transformersGenerator } from './transformers.js'; @@ -20,8 +20,8 @@ export type { // statically anyway). Compatibility metadata lives in BUILTIN_META — one home; // only the eagerly imported `run` functions live here. The pipeline entry never // touches this module: it loads built-ins lazily through the meta table. -const RUNS: Record> = { - sdk: { run: sdkGenerator }, +const RUNS: Record> = { + sdk: { run: sdkGenerator, sample: sdkSample }, zod: { run: zodGenerator }, transformers: { run: transformersGenerator }, 'tanstack-query': { run: tanstackQueryGenerator('react') }, diff --git a/packages/client-generator/src/generators/meta.ts b/packages/client-generator/src/generators/meta.ts index 53887141b5..6e522b02a0 100644 --- a/packages/client-generator/src/generators/meta.ts +++ b/packages/client-generator/src/generators/meta.ts @@ -7,8 +7,8 @@ import type { EmitOptions } from '../emitters/emit-options.js'; import { NotSupportedError } from '../errors.js'; import type { GeneratorDescriptor, GeneratorName } from './types.js'; -export type BuiltinMeta = Omit & { - load: () => Promise>; +export type BuiltinMeta = Omit & { + load: () => Promise>; }; function tanstackQuery(framework: 'react' | 'vue' | 'svelte' | 'solid'): BuiltinMeta { @@ -22,7 +22,9 @@ function tanstackQuery(framework: 'react' | 'vue' | 'svelte' | 'solid'): Builtin export const BUILTIN_META: Record = { // sdk is the base client; zod emits a standalone schema module importing nothing from it. - sdk: { load: () => import('./sdk.js').then((m) => ({ run: m.sdkGenerator })) }, + sdk: { + load: () => import('./sdk.js').then((m) => ({ run: m.sdkGenerator, sample: m.sdkSample })), + }, zod: { load: () => import('./zod.js').then((m) => ({ run: m.zodGenerator })) }, // transformers import the schema *types* from the sdk entry module (so sdk must run) and // assign `Date` values to those fields, which only type-checks when the sdk types dates as `Date`. diff --git a/packages/client-generator/src/generators/sdk.ts b/packages/client-generator/src/generators/sdk.ts index 8e60e0a329..2099b58fc5 100644 --- a/packages/client-generator/src/generators/sdk.ts +++ b/packages/client-generator/src/generators/sdk.ts @@ -1,8 +1,10 @@ import { join } from 'node:path'; 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 { Generator } from './types.js'; +import type { CodeSample, Generator, SampleContext } from './types.js'; /** * The default generator: the full typed client (model types + runtime + endpoints). @@ -26,3 +28,32 @@ export const sdkGenerator: Generator = ({ model, outputPath, outputMode, emit }) } return [{ path: outputPath, content: emitClientSingleFile(model, emit) }]; }; + +/** One idiomatic TS call per operation — the `x-codeSamples` reference implementation. */ +export function sdkSample(op: OperationModel, ctx: SampleContext): CodeSample { + const ident = packageIdents(ctx.model).get(op.name) ?? op.name; + const requiredQuery = op.queryParams.filter((param) => param.required); + const slots: string[] = []; + if (requiredQuery.length > 0) { + slots.push( + `params: { ${requiredQuery.map((param) => `'${param.name}': /* … */`).join(', ')} }` + ); + } + if (op.requestBody) slots.push('body: { /* … */ }'); + const args = + ctx.emit.argsStyle === 'grouped' + ? op.pathParams.length + slots.length > 0 + ? [ + `{ ${[...op.pathParams.map((param) => `'${param.name}': '<${param.name}>'`), ...slots].join(', ')} }`, + ] + : [] + : [ + ...op.pathParams.map((param) => `'<${param.name}>'`), + ...(slots.length > 0 ? [`{ ${slots.join(', ')} }`] : []), + ]; + return { + lang: 'typescript', + label: 'TypeScript SDK', + source: `import { ${ident} } from './client';\n\nconst result = await ${ident}(${args.join(', ')});\n`, + }; +} diff --git a/packages/client-generator/src/generators/types.ts b/packages/client-generator/src/generators/types.ts index 44fecb748c..1eb05d70c7 100644 --- a/packages/client-generator/src/generators/types.ts +++ b/packages/client-generator/src/generators/types.ts @@ -2,7 +2,7 @@ import type { EmitOptions } from '../emitters/emit-options.js'; import type { ErrorMode } from '../emitters/operations.js'; import type { DateType } from '../emitters/types.js'; -import type { ApiModel } from '../intermediate-representation/model.js'; +import type { ApiModel, OperationModel } from '../intermediate-representation/model.js'; /** * How the generated client is partitioned across files. @@ -46,6 +46,12 @@ export type GeneratorInput = { */ export type Generator = (input: GeneratorInput) => GeneratedFile[]; +/** One idiomatic call snippet for an operation, rendered for docs (`x-codeSamples`). */ +export type CodeSample = { lang: string; label?: string; source: string }; + +/** What a `sample` hook receives besides the operation. */ +export type SampleContext = { model: ApiModel; emit: EmitOptions }; + /** * A generator plus its declared compatibility contract. `validateGenerators` * checks these *before* anything is emitted, so an incompatible selection fails @@ -60,6 +66,9 @@ export type Generator = (input: GeneratorInput) => GeneratedFile[]; */ export type GeneratorDescriptor = { run: Generator; + /** Optional: one idiomatic call snippet per operation for docs (`x-codeSamples`); + * collected into an overlay when `codeSamples` is enabled. Return undefined to skip. */ + sample?: (operation: OperationModel, ctx: SampleContext) => CodeSample | undefined; // `string[]` (not `GeneratorName[]`) so a custom generator may require a built-in or another // custom generator by name; built-in descriptors still type-check (their names are strings). requires?: string[]; diff --git a/packages/client-generator/src/pipeline.ts b/packages/client-generator/src/pipeline.ts index 5d620050a8..ee6316eeee 100644 --- a/packages/client-generator/src/pipeline.ts +++ b/packages/client-generator/src/pipeline.ts @@ -6,6 +6,7 @@ // package. The `/generate` entry re-exports `generateClient` from here and // layers the sync TS toolkit on top. +import { stringifyYaml } from '@redocly/openapi-core'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, resolve } from 'node:path'; @@ -13,9 +14,14 @@ import type { EmitOptions } from './emitters/emit-options.js'; import { NotSupportedError } from './errors.js'; import { validateSelection } from './generators/meta.js'; import { resolveGenerators } from './generators/resolve.js'; -import type { GeneratedFile, GeneratorDescriptor, OutputMode } from './generators/types.js'; +import type { + CodeSample, + GeneratedFile, + GeneratorDescriptor, + OutputMode, +} from './generators/types.js'; import { buildApiModel } from './intermediate-representation/build.js'; -import type { ApiModel } from './intermediate-representation/model.js'; +import { allOperations, type ApiModel } from './intermediate-representation/model.js'; import { normalizeSwagger2 } from './intermediate-representation/normalize-swagger2.js'; import { loadSpec } from './loader.js'; import type { GenerateClientOptions, GenerateClientResult } from './types.js'; @@ -55,6 +61,38 @@ export function runGenerators( return files; } +/** + * An OpenAPI Overlay (1.0.0) adding per-operation `x-codeSamples`, collected from + * every selected generator that implements the `sample` hook; undefined when no + * generator contributed a sample. Docs tooling applies it to the description — + * generation stays side-effect-free on the source. + */ +function codeSamplesOverlay( + model: ApiModel, + emit: EmitOptions, + selected: string[], + registry: Map +): string | undefined { + const actions = []; + for (const op of allOperations(model.services)) { + const samples = selected + .map((name) => registry.get(name)?.sample?.(op, { model, emit })) + .filter((sample): sample is CodeSample => sample !== undefined); + if (samples.length > 0) { + actions.push({ + target: `$.paths['${op.path.replaceAll("'", "''")}'].${op.method}`, + update: { 'x-codeSamples': samples }, + }); + } + } + if (actions.length === 0) return undefined; + return stringifyYaml({ + overlay: '1.0.0', + info: { title: `Code samples for ${model.title}`, version: model.version }, + actions, + }); +} + export async function generateClient( options: GenerateClientOptions ): Promise { @@ -131,6 +169,13 @@ export async function generateClient( registry, }); + if (options.codeSamples === true) { + const overlay = codeSamplesOverlay(model, emit, selected, registry); + if (overlay !== undefined) { + files.push({ path: outputPath.replace(/\.[^.]+$/, '.code-samples.yaml'), content: overlay }); + } + } + const written: GenerateClientResult['files'] = []; for (const file of files) { await mkdir(dirname(file.path), { recursive: true }); diff --git a/packages/client-generator/src/types.ts b/packages/client-generator/src/types.ts index 5642e332ce..668a741489 100644 --- a/packages/client-generator/src/types.ts +++ b/packages/client-generator/src/types.ts @@ -88,6 +88,12 @@ export type GenerateClientOptions = { * `'ts'` suits runtimes that resolve specifiers literally, like Node's built-in * type stripping (`node client.ts`). */ importExt?: 'js' | 'ts'; + /** + * Emit `.code-samples.yaml` — an OpenAPI Overlay adding per-operation + * `x-codeSamples` collected from every selected generator that implements `sample()`. + * Config-only (`client.codeSamples`), like `pagination`. + */ + codeSamples?: boolean; /** * Auto-pagination rules: a convention rule (applied to every operation it * structurally fits), per-operation overrides, and `exclude`d operationIds — diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts index 15f84c08db..760d5c4fdf 100644 --- a/packages/core/src/types/redocly-yaml.ts +++ b/packages/core/src/types/redocly-yaml.ts @@ -379,6 +379,7 @@ const Client: NodeType = { mockData: { enum: ['static', 'faker'] }, mockSeed: { type: 'number' }, queryKeyPrefix: { type: 'string' }, + codeSamples: { type: 'boolean' }, setup: { type: 'string' }, pagination: 'ClientPagination', }, diff --git a/tests/e2e/generate-client/redocly-config.test.ts b/tests/e2e/generate-client/redocly-config.test.ts index da7662be32..b031256638 100644 --- a/tests/e2e/generate-client/redocly-config.test.ts +++ b/tests/e2e/generate-client/redocly-config.test.ts @@ -161,6 +161,26 @@ describe('generate-client redocly.yaml config', () => { rmSync(dir, { recursive: true, force: true }); }, 60_000); + it('client.codeSamples emits an x-codeSamples overlay next to the client', () => { + const dir = project( + [ + 'apis:', + ' cafe:', + ' root: ./openapi.yaml', + ' clientOutput: ./out.ts', + ' client:', + ' generators: [sdk]', + ' codeSamples: true', + ].join('\n') + '\n' + ); + const res = run(dir, ['cafe']); + expect(res.status, res.stderr).toBe(0); + const overlay = readFileSync(join(dir, 'out.code-samples.yaml'), 'utf-8'); + expect(overlay).toContain('x-codeSamples'); + expect(overlay).toContain('lang: typescript'); + rmSync(dir, { recursive: true, force: true }); + }, 60_000); + it('a per-api client block REPLACES the top-level one (no field-by-field merging)', () => { // One resolution path, obvious to reason about: an api with its own `client` // uses that block wholesale; the top-level block only serves apis without one. From 357e49eed79a99b686e2c887447d005f7672bc3e Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 10:45:29 +0300 Subject: [PATCH 006/211] =?UTF-8?q?docs(client-generator):=20authoring=20s?= =?UTF-8?q?kill=20v1=20=E2=80=94=20AGENTS.md=20template,=20guard=20test,?= =?UTF-8?q?=20and=20guide=20rework?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/@v2/commands/generate-client.md | 1 + docs/@v2/configuration/reference/client.md | 1 + .../@v2/guides/customize-client-generation.md | 31 +++++++- .../client-generator/eject-assets/AGENTS.md | 72 +++++++++++++++++++ packages/client-generator/package.json | 3 +- .../src/__tests__/agents-template.test.ts | 30 ++++++++ 6 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 packages/client-generator/eject-assets/AGENTS.md create mode 100644 packages/client-generator/src/__tests__/agents-template.test.ts diff --git a/docs/@v2/commands/generate-client.md b/docs/@v2/commands/generate-client.md index c9d2471a82..c9d2662fbe 100644 --- a/docs/@v2/commands/generate-client.md +++ b/docs/@v2/commands/generate-client.md @@ -59,6 +59,7 @@ redocly generate-client [--help] [--version] Instead of passing flags every time, keep the settings in `redocly.yaml` under a top-level `client` block and per-API `apis..client` / `clientOutput` — see the [`client` configuration reference](../configuration/reference/client.md) for the fields. CLI flags take precedence over the configuration. Auto-pagination has no CLI flag; it's declared only as [`client.pagination`](../configuration/reference/client.md#pagination-object) configuration or the `x-redocly-pagination` operation extension. +Code samples for docs are config-only too: [`client.codeSamples`](../configuration/reference/client.md) emits an `x-codeSamples` overlay next to the client. ```yaml client: diff --git a/docs/@v2/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md index da8a7cebc9..3e92fad1c3 100644 --- a/docs/@v2/configuration/reference/client.md +++ b/docs/@v2/configuration/reference/client.md @@ -29,6 +29,7 @@ For runs without a configuration file, declare pagination per operation with the | `mockData` | string | Data mode for the `mock` generator: `static` or `faker`. | | `mockSeed` | number | Seed for `faker`-mode mocks. | | `queryKeyPrefix` | string | Leading element for every `tanstack-query` query/mutation key — namespaces the cache when several generated APIs share one QueryClient. Config-only, no flag. | +| `codeSamples` | boolean | Emit `.code-samples.yaml` — an OpenAPI Overlay adding per-operation `x-codeSamples` collected from every selected generator that implements `sample()`. Config-only, no flag. | | `serverUrl` | string | Server URL included in the client as its default; falls back to `servers[0].url`. | | `setup` | string | Path to a publisher setup module that gets included in the client — pre-configures defaults such as the server URL, retries, headers, and middleware. See [Publisher defaults](../../guides/customize-client-generation.md#publisher-defaults). | | `pagination` | [Pagination object](#pagination-object) | Declares how the API paginates, so paginated operations gain typed `.pages()`/`.items()` async iterators. | diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index 9f49f942a9..c228477825 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -67,7 +67,30 @@ The built-in generators cover common targets. For anything else derived from the same description (validators in another library, a permissions map, a house-style SDK), write a **custom generator**: it reads the same API model the built-ins consume, so its output never drifts from the description. A generator adds artifacts _next to_ the client — it doesn't change the generated client's behavior; for that, use [publisher defaults](#publisher-defaults) or let the consumer compose [middleware](./use-generated-client.md#middleware). -A generator is `{ name, run }` (plus optional compatibility metadata); author it with `defineGenerator` from the package root, and build real TypeScript with the emit toolkit from `@redocly/client-generator/generate` — the same `ts.factory` + printer the built-in generators use, so the schema→type mapping matches the sdk's exactly: +A generator is `{ name, run }` (plus optional compatibility metadata); author it with `defineGenerator` from the package root. +The output is text, so a generator can emit **any language** — Python models, a Go client, a permissions matrix — not just TypeScript. + +### Language-neutral helpers + +The package root exports pure helpers over the API model that cover the cross-language variance points, so a generator in any output language never re-implements schema semantics: + +| 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 (sealed hierarchy, type switch, `Union` — each language renders its own). | +| `isNullable(schema)` / `unwrapNullable(schema)` | Detect and strip `null` union members (`Optional[T]`, pointers, `Option`). | +| `enumValues(schema)` | Values plus SCREAMING_SNAKE member-name suggestions. | +| `casing` / `identifierFor(name, opts)` | camel/pascal/snake/screaming casing; keyword-safe identifiers (`RESERVED_WORDS.python/go/typescript` shipped, pass your own set). | +| `CodeWriter` | Indentation-aware text builder — no manual whitespace bookkeeping. | +| `docText(description)` | Description text as trimmed lines for any comment syntax. | + +A generator that imports only these helpers (and not the TypeScript toolkit below) runs without the `typescript` package installed. + +For a repo-local, agent-readable version of this guidance, copy the [`AGENTS.md` template](https://github.com/Redocly/redocly-cli/blob/main/packages/client-generator/eject-assets/AGENTS.md) into your generators directory — it gives any coding agent the contract, the model reference, and this helper table. + +### TypeScript artifacts + +For TypeScript output, build real syntax trees with the emit toolkit from `@redocly/client-generator/generate` — the same `ts.factory` + printer the built-in generators use, so the schema→type mapping matches the sdk's exactly: ```ts // response-map-generator.ts @@ -139,6 +162,12 @@ await generateClient({ }); ``` +### Code samples for docs + +A generator that knows how to call an operation can also document it: implement the optional `sample(operation, ctx)` hook to return one idiomatic snippet (`{ lang, label, source }`) per operation. +With `codeSamples: true` in the `client` block, generation collects every selected generator's samples into `.code-samples.yaml` — an [OpenAPI Overlay](https://spec.openapis.org/overlay/latest.html) adding `x-codeSamples` per operation, ready for docs tooling to apply. +The built-in `sdk` generator ships the TypeScript reference implementation, so enabling the flag alone gives your Redoc docs per-operation TypeScript examples that never drift from the SDK. + Import-specifier generators execute at generation time — they carry the same trust level as any installed dependency you run. See the [`ast-toolkit-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/ast-toolkit-generator) for the runnable toolkit-based plugin (including type-importing referenced schemas), the [`custom-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/custom-generator) for a minimal string-building one, and the [`nested-facade` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/nested-facade) for a realistic one that derives an `api..` facade from the description's tags. diff --git a/packages/client-generator/eject-assets/AGENTS.md b/packages/client-generator/eject-assets/AGENTS.md new file mode 100644 index 0000000000..017325b5f4 --- /dev/null +++ b/packages/client-generator/eject-assets/AGENTS.md @@ -0,0 +1,72 @@ + + +# Writing custom client generators + +A generator is a plain module: `(input) => GeneratedFile[]`. It receives the +language-agnostic API model and returns files — in ANY output language. It runs +in the same pass as the built-ins; select it by path in `redocly.yaml`: + +```yaml +client: + generators: [sdk, ./generators/my-generator.mjs] +``` + +## The contract + +```js +/** @type {import('@redocly/client-generator').CustomGenerator} */ +export default { + name: 'my-generator', + run({ model, outputPath, outputMode, emit }) { + return [{ path: outputPath.replace(/\.ts$/, '.mine.txt'), content: '…' }]; + }, + // Optional: one idiomatic call snippet per operation for docs (x-codeSamples), + // collected into an overlay file when `client.codeSamples: true` is set. + sample(operation, { model, emit }) { + return { lang: 'python', source: '…' }; + }, +}; +``` + +Rules: output is deterministic (same description → same bytes); never add +dependencies to the generated client; **never hand-edit generated output** — +edit this generator and regenerate. + +## The model (IR) + +`model.services[].operations[]` — each operation carries `name`, `specName`, +`method`, `path`, `tags`, `pathParams`/`queryParams`/`headerParams`/`cookieParams`, +`requestBody`, `successResponses`/`errorResponses` (each with a `schema`), and +`security`. `model.schemas` holds the named schemas. Every schema is a +discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, +`literal`, `enum`, `union` (optionally with a discriminator), `intersection` +(allOf), `null`, `unknown`, `omit`. + +## Helpers (import from '@redocly/client-generator') + +| Helper | Use | +| ----------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | +| `flattenAllOf(schema, model)` | The merged property view of allOf compositions — languages without intersection types render this. | +| `discriminatorCases(schema, model)` | `{ property, cases }` dispatch table for discriminated unions. | +| `isNullable(schema)` / `unwrapNullable(schema)` | Detect and strip `null` union members (`Optional[T]`, pointers, `Option`). | +| `enumValues(schema)` | Values plus SCREAMING_SNAKE member-name suggestions. | +| `casing` / `identifierFor(name, { style, reserved })` | camel/pascal/snake/screaming; keyword-safe identifiers (`RESERVED_WORDS.python/go/typescript` shipped). | +| `CodeWriter` | Indentation-aware text builder — no manual whitespace bookkeeping. | +| `docText(description)` | Description as trimmed lines for any comment syntax. | +| `AUTHORING_HELPER_NAMES` | The list of the above (introspection). | + +TypeScript-emitting generators may additionally use the TS toolkit from +`@redocly/client-generator/generate` (`ts`, `printStatements`, `schemaToTypeNode`, …). + +## The loop + +1. Edit the generator. +2. Run `redocly generate-client`. +3. Inspect `git diff` of the generated output. +4. Repeat. Generated files are never hand-edited. + +If you had to work around a **missing helper** or a wrong default, tell the user +and suggest filing an issue at https://github.com/Redocly/redocly-cli/issues — +include the generator snippet and the helper you expected to exist. + + diff --git a/packages/client-generator/package.json b/packages/client-generator/package.json index 68a383e38e..8177085239 100644 --- a/packages/client-generator/package.json +++ b/packages/client-generator/package.json @@ -64,6 +64,7 @@ "typescript": "6.0.2" }, "files": [ - "lib" + "lib", + "eject-assets" ] } diff --git a/packages/client-generator/src/__tests__/agents-template.test.ts b/packages/client-generator/src/__tests__/agents-template.test.ts new file mode 100644 index 0000000000..f0e2f2d6d9 --- /dev/null +++ b/packages/client-generator/src/__tests__/agents-template.test.ts @@ -0,0 +1,30 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { AUTHORING_HELPER_NAMES } from '../authoring/index.js'; + +const template = readFileSync( + resolve(dirname(fileURLToPath(import.meta.url)), '../../eject-assets/AGENTS.md'), + 'utf-8' +); + +describe('eject-assets/AGENTS.md (the authoring skill template)', () => { + it('documents every neutral helper — the skill cannot drift from the exports', () => { + for (const name of AUTHORING_HELPER_NAMES) { + expect(template.includes('`' + name), name).toBe(true); + } + }); + + it('carries the contract, the verify loop, and the feedback instruction', () => { + for (const marker of [ + 'GeneratedFile', + 'redocly generate-client', + 'never hand-edit', + 'sample(', + 'missing helper', + ]) { + expect(template.toLowerCase()).toContain(marker.toLowerCase()); + } + }); +}); From d08648f8feaa1250d442587aa2f4102aff1ea077 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 10:49:52 +0300 Subject: [PATCH 007/211] =?UTF-8?q?feat(cli):=20generate-client=20telemetr?= =?UTF-8?q?y=20=E2=80=94=20generator=20usage=20events=20and=20toolkit-impo?= =?UTF-8?q?rt=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/@v2/usage-data.md | 2 + .../generate-client-telemetry.test.ts | 40 +++++++++++++ packages/cli/src/commands/generate-client.ts | 40 ++++++++++++- .../src/utils/generate-client-telemetry.ts | 57 +++++++++++++++++++ packages/cli/src/utils/telemetry.ts | 15 +++++ packages/cli/src/wrapper.ts | 2 + 6 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/__tests__/generate-client-telemetry.test.ts create mode 100644 packages/cli/src/utils/generate-client-telemetry.ts diff --git a/docs/@v2/usage-data.md b/docs/@v2/usage-data.md index 7f6302d9d7..e588a35211 100644 --- a/docs/@v2/usage-data.md +++ b/docs/@v2/usage-data.md @@ -21,6 +21,8 @@ When a command is run, the following data is collected: - API specification type and version - names of lint rules that reported errors, warnings, or ignored problems - Arazzo x-security authentication types +- for `generate-client`: which built-in generators ran, the count of custom generators, which of the package's own exported helper names a custom generator imports, and a coarse error category on failure. + Custom generator file contents, paths, and names are never collected. - platform (Linux, macOS, Windows) - anonymous ID (a randomly generated identifier that doesn't contain personal information) - command execution time diff --git a/packages/cli/src/__tests__/generate-client-telemetry.test.ts b/packages/cli/src/__tests__/generate-client-telemetry.test.ts new file mode 100644 index 0000000000..a25bbeadbb --- /dev/null +++ b/packages/cli/src/__tests__/generate-client-telemetry.test.ts @@ -0,0 +1,40 @@ +import { + categorizeGenerateClientError, + collectToolkitImports, +} from '../utils/generate-client-telemetry.js'; + +describe('collectToolkitImports', () => { + it('returns only OUR helper names from client-generator imports — never user identifiers', () => { + const source = [ + "import { flattenAllOf, CodeWriter, mySecretHelper } from '@redocly/client-generator';", + "import { printStatements } from '@redocly/client-generator/generate';", + "import { internalThing } from './our-private-module.js';", + ].join('\n'); + expect( + collectToolkitImports(source, ['flattenAllOf', 'CodeWriter', 'printStatements']) + ).toEqual(['flattenAllOf', 'CodeWriter', 'printStatements']); + }); + + it('handles aliased and type-only named imports', () => { + const source = + "import { type flattenAllOf, CodeWriter as Writer } from '@redocly/client-generator';"; + expect(collectToolkitImports(source, ['flattenAllOf', 'CodeWriter'])).toEqual([ + 'flattenAllOf', + 'CodeWriter', + ]); + }); +}); + +describe('categorizeGenerateClientError', () => { + it('maps known failure shapes to coarse categories', () => { + expect(categorizeGenerateClientError('Invalid pagination configuration:…')).toBe('pagination'); + expect(categorizeGenerateClientError('Could not load generator "./x.mjs": …')).toBe( + 'generator-load' + ); + expect(categorizeGenerateClientError('Unknown generator: foo')).toBe('not-supported'); + expect( + categorizeGenerateClientError('The "swr" generator does not support --error-mode "result"') + ).toBe('not-supported'); + expect(categorizeGenerateClientError('boom')).toBe('other'); + }); +}); diff --git a/packages/cli/src/commands/generate-client.ts b/packages/cli/src/commands/generate-client.ts index 47212e5468..334ff564bc 100644 --- a/packages/cli/src/commands/generate-client.ts +++ b/packages/cli/src/commands/generate-client.ts @@ -1,8 +1,15 @@ import { type GenerateClientConfig } from '@redocly/client-generator'; import { HandledError, isPlainObject, logger, pluralize } from '@redocly/openapi-core'; import { blue, gray, yellow } from 'colorette'; +import { readFileSync } from 'node:fs'; import { basename, dirname, extname, isAbsolute, resolve as resolvePath } from 'node:path'; +import { + BUILTIN_GENERATOR_NAMES, + categorizeGenerateClientError, + collectToolkitImports, + generateClientTelemetry, +} from '../utils/generate-client-telemetry.js'; import { getFallbackApisOrExit } from '../utils/miscellaneous.js'; import { type CommandArgs } from '../wrapper.js'; @@ -61,7 +68,8 @@ export async function handleGenerateClient({ argv, config, }: CommandArgs) { - const { generateClient, mergeConfig } = await import('@redocly/client-generator'); + const { AUTHORING_HELPER_NAMES, generateClient, mergeConfig } = + await import('@redocly/client-generator'); const configDir = config.configPath ? dirname(config.configPath) : process.cwd(); @@ -121,6 +129,7 @@ export async function handleGenerateClient({ configDir ); const clientConfig = mergeConfig(clientBlock, cliFlags); + collectGeneratorUsage(clientConfig.generators ?? [], AUTHORING_HELPER_NAMES); const outputPath = argv.output !== undefined @@ -162,9 +171,38 @@ export async function handleGenerateClient({ logger.info('\n' + blue(summary) + '\n'); } catch (error) { const message = error instanceof Error ? error.message : String(error); + generateClientTelemetry.generate_client_error_category = + categorizeGenerateClientError(message); throw new HandledError( `\n❌ Failed to generate TypeScript client for ${name}.\n ${message}\n` ); } } } + +/** Telemetry: allowlisted built-in names, custom count, and OUR helper names a + * path generator imports — never user code, paths, or names. */ +function collectGeneratorUsage(entries: string[], knownHelpers: readonly string[]): void { + const builtins = new Set(generateClientTelemetry.generate_client_builtin_generators ?? []); + const toolkitImports = new Set(generateClientTelemetry.generate_client_toolkit_imports ?? []); + let customCount = generateClientTelemetry.generate_client_custom_generators_count ?? 0; + for (const entry of entries) { + if (BUILTIN_GENERATOR_NAMES.has(entry)) { + builtins.add(entry); + continue; + } + customCount++; + if (entry.startsWith('.') || isAbsolute(entry)) { + try { + for (const helper of collectToolkitImports(readFileSync(entry, 'utf-8'), knownHelpers)) { + toolkitImports.add(helper); + } + } catch { + // Unreadable path: generation fails later with its own error; nothing to record. + } + } + } + generateClientTelemetry.generate_client_builtin_generators = [...builtins]; + generateClientTelemetry.generate_client_custom_generators_count = customCount; + generateClientTelemetry.generate_client_toolkit_imports = [...toolkitImports]; +} diff --git a/packages/cli/src/utils/generate-client-telemetry.ts b/packages/cli/src/utils/generate-client-telemetry.ts new file mode 100644 index 0000000000..0061661edc --- /dev/null +++ b/packages/cli/src/utils/generate-client-telemetry.ts @@ -0,0 +1,57 @@ +// generate-client telemetry collectors. Two hard rules (documented on the +// telemetry docs page): user code contents, paths, and generator names are never +// transmitted — only the names of OUR exported helpers a custom generator imports, +// counts, and coarse error categories. Everything rides the REDOCLY_TELEMETRY opt-out. + +export type GenerateClientTelemetry = { + generate_client_builtin_generators?: string[]; + generate_client_custom_generators_count?: number; + generate_client_toolkit_imports?: string[]; + generate_client_error_category?: string; +}; + +/** Populated by handleGenerateClient; spread into the telemetry payload by the wrapper. */ +export const generateClientTelemetry: GenerateClientTelemetry = {}; + +/** Allowlist for the builtin-usage event — anything not here is counted, never named. */ +export const BUILTIN_GENERATOR_NAMES = new Set([ + 'sdk', + 'zod', + 'tanstack-query', + 'tanstack-query-vue', + 'tanstack-query-svelte', + 'tanstack-query-solid', + 'swr', + 'transformers', + 'mock', +]); + +const IMPORT_RE = + /import\s*(?:type\s*)?\{([^}]*)\}\s*from\s*['"]@redocly\/client-generator(?:\/generate)?['"]/g; + +/** Names of OUR exports found in an import from '@redocly/client-generator[/generate]'. */ +export function collectToolkitImports(source: string, knownHelpers: readonly string[]): string[] { + const known = new Set(knownHelpers); + const found = new Set(); + for (const match of source.matchAll(IMPORT_RE)) { + for (const raw of match[1].split(',')) { + const name = raw + .trim() + .replace(/^type\s+/, '') + .split(/\s+as\s+/)[0] + .trim(); + if (known.has(name)) found.add(name); + } + } + return [...found]; +} + +/** Coarse category from an error message — never the message itself. */ +export function categorizeGenerateClientError(message: string): string { + if (message.includes('Invalid pagination configuration')) return 'pagination'; + if (message.includes('Could not load generator')) return 'generator-load'; + if (message.includes('Unknown generator') || message.includes('does not support')) { + return 'not-supported'; + } + return 'other'; +} diff --git a/packages/cli/src/utils/telemetry.ts b/packages/cli/src/utils/telemetry.ts index 58ebb6a3da..1662899e00 100644 --- a/packages/cli/src/utils/telemetry.ts +++ b/packages/cli/src/utils/telemetry.ts @@ -22,6 +22,7 @@ import type { CriterionObject } from '../../../core/src/typings/arazzo.js'; import { getReuniteUrl } from '../reunite/api/index.js'; import type { CommandArgv } from '../types.js'; import { ANONYMOUS_ID_CACHE_FILE } from './constants.js'; +import type { GenerateClientTelemetry } from './generate-client-telemetry.js'; import type { ExitCode } from './miscellaneous.js'; import { respondWithinMs } from './network-check.js'; import { version } from './package.js'; @@ -46,6 +47,7 @@ export async function sendTelemetry({ lint_rules_with_errors, lint_rules_with_warnings, lint_rules_with_ignored_problems, + generate_client, }: { config: Config | undefined; argv: Arguments | undefined; @@ -60,6 +62,7 @@ export async function sendTelemetry({ lint_rules_with_errors: string[] | undefined; lint_rules_with_warnings: string[] | undefined; lint_rules_with_ignored_problems: string[] | undefined; + generate_client?: GenerateClientTelemetry; }): Promise { try { if (!argv) { @@ -128,6 +131,18 @@ export async function sendTelemetry({ lint_rules_with_ignored_problems: lint_rules_with_ignored_problems?.length ? JSON.stringify(lint_rules_with_ignored_problems) : undefined, + // generate-client usage (names of OUR generators/helpers only — never user + // code, paths, or names; see utils/generate-client-telemetry.ts). + generate_client_builtin_generators: generate_client?.generate_client_builtin_generators + ?.length + ? JSON.stringify(generate_client.generate_client_builtin_generators) + : undefined, + generate_client_custom_generators_count: + generate_client?.generate_client_custom_generators_count, + generate_client_toolkit_imports: generate_client?.generate_client_toolkit_imports?.length + ? JSON.stringify(generate_client.generate_client_toolkit_imports) + : undefined, + generate_client_error_category: generate_client?.generate_client_error_category, }, ]; diff --git a/packages/cli/src/wrapper.ts b/packages/cli/src/wrapper.ts index c1afeca448..c70fa4e2c1 100644 --- a/packages/cli/src/wrapper.ts +++ b/packages/cli/src/wrapper.ts @@ -16,6 +16,7 @@ import type { Arguments } from 'yargs'; import type { CommandArgv } from './types.js'; import { AbortFlowError, exitWithError } from './utils/error.js'; +import { generateClientTelemetry } from './utils/generate-client-telemetry.js'; import { loadConfigAndHandleErrors, type ExitCode } from './utils/miscellaneous.js'; import { version } from './utils/package.js'; import { @@ -146,6 +147,7 @@ export function commandWrapper( lint_rules_with_errors: [...lintRulesWithErrors], lint_rules_with_warnings: [...lintRulesWithWarnings], lint_rules_with_ignored_problems: [...lintRulesWithIgnoredProblems], + generate_client: generateClientTelemetry, }); } process.once('beforeExit', () => { From 7eec23a3ed44c8bcd381b8d9d10b6e19e84a3ef3 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 10:53:06 +0300 Subject: [PATCH 008/211] chore: changeset for the agent-friendly authoring toolkit slice --- .changeset/agent-friendly-slice1.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/agent-friendly-slice1.md diff --git a/.changeset/agent-friendly-slice1.md b/.changeset/agent-friendly-slice1.md new file mode 100644 index 0000000000..63e3294f6e --- /dev/null +++ b/.changeset/agent-friendly-slice1.md @@ -0,0 +1,6 @@ +--- +'@redocly/client-generator': minor +'@redocly/cli': minor +--- + +Added the language-neutral generator-authoring toolkit (`flattenAllOf`, `discriminatorCases`, nullability and enum helpers, casing/identifier utilities, and `CodeWriter`), available from the package root so custom generators in any output language never load TypeScript; the generation pipeline now loads built-in generators lazily. Generators can implement a `sample()` hook, and `client.codeSamples: true` emits an OpenAPI Overlay adding per-operation `x-codeSamples` (the TypeScript sdk ships the reference implementation). From 7e4dd6d4dccc4fdc4580261132a55ea5de84d87d Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 11:11:38 +0300 Subject: [PATCH 009/211] feat(client-generator): Python runtime core (errors, auth, send loop) with build-time embed --- .../__pycache__/_auth.cpython-314.pyc | Bin 0 -> 4349 bytes .../__pycache__/_errors.cpython-314.pyc | Bin 0 -> 3168 bytes .../__pycache__/_send.cpython-314.pyc | Bin 0 -> 6754 bytes .../client-generator/python-runtime/_auth.py | 68 +++++++++ .../python-runtime/_errors.py | 48 +++++++ .../client-generator/python-runtime/_send.py | 129 ++++++++++++++++++ .../scripts/generate-runtime-sources.mjs | 23 ++++ .../src/emitters/python-runtime-sources.ts | 11 ++ .../__tests__/python-runtime-embed.test.ts | 32 +++++ 9 files changed, 311 insertions(+) create mode 100644 packages/client-generator/python-runtime/__pycache__/_auth.cpython-314.pyc create mode 100644 packages/client-generator/python-runtime/__pycache__/_errors.cpython-314.pyc create mode 100644 packages/client-generator/python-runtime/__pycache__/_send.cpython-314.pyc create mode 100644 packages/client-generator/python-runtime/_auth.py create mode 100644 packages/client-generator/python-runtime/_errors.py create mode 100644 packages/client-generator/python-runtime/_send.py create mode 100644 packages/client-generator/src/emitters/python-runtime-sources.ts create mode 100644 packages/client-generator/src/generators/__tests__/python-runtime-embed.test.ts diff --git a/packages/client-generator/python-runtime/__pycache__/_auth.cpython-314.pyc b/packages/client-generator/python-runtime/__pycache__/_auth.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bde65a3bfa7cda44a471a3f39c85aabdb2fc32f7 GIT binary patch literal 4349 zcmbVPUu+Y}8K1Si>y4e*4g~B#h&LpJnEWw+0^|Zjf&g(y;gTg70)%K|Pm(3h+RU!g zB&sS)*N0rIE2ei9xrgdU1bgg=?~v&w|6-a`kFQhiO*NK*S~>E3f)37*NsA)!TD+3 z(rqy;P+GgKA)8?f@dh6f3?XDU>><$*LnTHDle2X@LQcc!M}sAUrGtD68Z13xY1&no z1fh*BqfF+#NEW;(<}qBd9qJ;hyX6w7J6PQ#TWV3j`GP75aHdnyw78)dYFyJV1q9Q2 zTAMb-P9+vohGW>YomC@-Y5R$)8z$eMOu#SxiUz_kl)saV8#uy&BKpx}gU<@h*Ai4x zD2#4d+J1ph6#iAAK5mSYIoTpxWm}Lgf`4HFF;h&C_@o-e#4PKNk7KR3&=d$Zl0-AD zxg*97qSg7^RkR})Icw_vA_jh(QK~c zB`p@KS@Gm7`gnL$!PsSNm#-bnQOZShOW;WTHbJw!d!3BIR%vn z4rswmC%A&6Mj4jqq1RS`v9$P_=%wOPO%+^xEMyY_^w2iA;)D!^7 zJ$Rbzh6znOG=|3Uv?&Z@g3l$l6b4F~=Io)^L-fv5HWt~;?XEV-*jIH7S z#~q+f78vgAdEALQjwFd{Owa71@#zN3HezsaFmH$|*d0US%sv*xZmfv`UI&|`jqSl7 zg(`K0UdL?q7BIc*70q(4hL=`a9_(P{SNwrjJhZ=$R(f%OU-;MkkSpAV(7+vmC<&oj z3?ol57=%#)e^D4q1I4Wk_`b-spw`v%kgx<%!U~_Q1ts{f#e>dq=dU1?5W>Pn)FmuTQ$VFDs=Tjm`_|kA4PS~&p@w3E?Fn{(Ux*)kmGypn>G#2&C;cC zh2AVF$c0N}r(Ak`)#jz>a4}Z+QO{psv|TP^b2t7E%a#@Q4VJ+yg2=9|>;n2NVXN$B zJA=Es=ilguyyYqCbcUTHHahz`t_Q3hcFN`Jtpx3D>)Yh{O-5+w4tr^2-;`=lxD;%# zJzPe2+e-ucrX*Js<}Q@IH0Ezg@{U4p!X@tvyVmawRthn?1XY7iiin4+stU>8UV7ba zN@2dB3#{X#*16hxgmR0@WH|)%Gf6cTl^Vvd0`x*}lI|oinGQ%mBc!;7B_IjF1Axu! z*QG)*k=~V?m6(Bv20fElmqti@!eNYIDbF6H2*gcb5vmf?TcoRFSd+B4v~Gl?!kA<% zHZ85i*(Ql97*UCCIM#{6v}-a_{ux!4wSeWXz#@e>P^{l z(F6F|a%{nah4J|tw&xrmmYYH;9?la)3#J8F$5E@w1#DaIUA7cr z$f{mI6w?d$IMqwMpa2*O#Zka&T3Qa8oX&2L{4FY_^0#QoNlGE75~RZn6v!8&jwfSr zU}}0tOHp90RtrjXdM$tR0?eEV4m|C<_QmN(uE%$t)U6!s${g&<_Fel{hXae=tE}SA z_fz6$1>Of&dK3IK?W+L!{(?vg?f3u4QrH3x7Z)I2|5Qw0ec1V@&Ly^%Y;fRV=flp= z*P1I$19!c@^!{G_R;O(7J@@Gf?!yYmKHm-5UmXs}8OI=$w2{3uV;sezYNn7sbf!%M zY|^&^oIH~|WrBT8o34ytLf126Kf_pzWR{%!DE*OUEGHyW=#0n5RUB}@w0O!O`{BOFjC zMaRcl&643`tyqi1qu3M*WfZfv?RH$%0&dbkHBhg?OKB}CnD*6>LMXF0?GZMuo~K?C z9YR^U$xkY=BsN8g3L0kEuE+`6PoZk^jIrx9lGcw%>r)RhgJcdG08wmq`~C3GVn z{Cw61$H#8f{xxLTa z{(pFU&nx!Mw`D7uX3yokd+yKNo0+d$5VPJxOk}QY$z7W}5S$a{CV%J3diLkaE9b5* zmDlAOn$sh5;ooXm&%T!eD)-%Y-E+;?FYNqdOQz<~V)>(^k9RzgmyU!ohr2WW3xD_Y zvbXP39rQE&)3+>DMjy>id-(GI(o@;pSt6uRnJo7aygcjNt`xd7b zZ)XChR|27@fzTJ;Y@jdelUIBLPkjTK>q^!)oaS@QM;6a59?vwMpwlhEbcfLP>bHlH z=+0G1pLBoRJwKYQYMHgC#iwFr&be*2BV(&%)GttOI);WulJJ261A_O_J5-4uRJ@_k-4_{GYi)FuI1fL anVn6`p5|298v)+9Z$x;6z1>z|v;PH{wzH4` literal 0 HcmV?d00001 diff --git a/packages/client-generator/python-runtime/__pycache__/_errors.cpython-314.pyc b/packages/client-generator/python-runtime/__pycache__/_errors.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..47c78e4ed3710a9e8b609b965b0c14197f8db14f GIT binary patch literal 3168 zcmb7GO>7&-6`t8YE`KE1QS=u((MnEK6S2fVcH1B|8V89{DM*!OqezF8UaglyaVu(< znH@$Z4~2jftpm8Jfac(1kM+%m9&+^2M+Npl85F3~qKDk%QjLS2`ra%_P-^-&z`plp z=FRMz_vU+VUK}3E5oo{tNA$A=i;%xyr`POMpq)Q}uuHBHmoAefD(E!I+OoE!3th?j zvaw_ebIB6cQbuH!Y++B6vOPmw;}T7G5n0tY`~BP)fq83!xS4YPlHS!Ss8Jh6vjd|; zWnGQuU_4*8%Gq-6qITh#bPdwp`b*s*G3*x3k>b!-*e_C<_oFBlz6j$efo`_q3qPp) zNm4YV{#w+MnIEx;@i36~+YQw6>oT+2YOpn*OS&rQ)u6kxVqI%#XA!zS_@+z*b&1ff z4GZ0+!f>?-vPLdaSDzqtyM9WTt|6>7@&hU|V3#TF*Baqf&STzw%k{&ARh;vp#^W7l zJFc*zQ;oUfJ5e0XFW$J}aF#T{aCX57e9l{8wCRW%b1EzV<%+{_ALn{pX)Ofk?XJfO zMiwlkoeLoBf<4!?mw!P>1;);jcW8~e)YV+wHCz+8{NCW@NO;ooy6HOjTVrn_e<|3j3=dS zpC>i;VY1a~m3CsjRoVXHZ-nzfJuEf+V9VcRNeMbE67!oF8(+k{)M$wsIE6PO z5pJ`Rr;eU1G+MIYdEFQ@&y!<4_bv<$p9V9M5|CZ;gqY^kV<+o~N+jc?qNdGKX!KPvP2WAoBEpGt*xvN6y3CH@oDI z_Rzvev;*9VMWnU1`XJU z=H9W<{>b0P;N?6IP|&QS3}OAC>DRk6Bc4|cc_Qjz#G)9cGoDw8130rXbPP^_AP+oW z2p(Q<3YLJ(c^S}uBb``F2+!QJuG(UySyBT4p#v;cD^rzv6GqLT4aM+E2u}Y#+ zKoCdOaI?wbS|A``7w6BrpQ}O>WE{x^kRs)iz|x$O#t#dDJbS{E$B_Rlu1Jl2LM(IoF_PcCdx-4r zcNMz_$cNtjOKMI9Jm|v`mS^FEGiN*3KsW(;qYrsnfGUZ=ceOPgsJ@u_{}ORve73Uloiy?Shjy=X)1I(&9?dP zNw`n`t0Pm?D<@ut6$7Y07@oLY*!%hZ3*Y}NbGLP`aDV;&^8R~k`-Sx*UCZVUE%I#f zp*?jl@!YKfkWUI}63~@p116nA3tt2>h)c{c90}&41d|!y^07wEx9JlLV)Zo!EQKa& z1@QG7gbEw&`~>Jgfu0lk^AI8|(Ph)s=U~<~Kr)qNfs{e1Y`N+Y0_FQu|51@~bHaA> z5TJszT$VKJqWx;h=do@BRIva$NzHGtR{*GfAnGj#s-Y8Coeg|+Y?L-s&DwCn#7US~ zXy1(5v;OagO& zsf#eD04}w7O9CR$;S2HB0?uLy+9i)hMt{@(xP5zde`IFgo=NWlW{DNZHqQg+FCf8) z^6wzQ93Y{L47>-SLf8$CNlCQ$6;vuB!Clh*R3&^FyGU>^l}dg^zBHB}<;ETuV+Z;1 z2gdlp@ad0!{Ky6j$;#O_KErH_3@s7%T|1HWAZs3R|S`B#P zsgQFEMtBAZCS8tMh~vXfX0JjWP;XULmlRHvfhR1N_T{vi{*W)hIt)*;0pw7pl>R+O z=()d=nLm-m2W0U}BY*StkKWp)U-X0{%cfH|TSo-g(Rhwd?}bMM*x@ro%O7c3I(u_{ Y@15IpZ}r#Z4uLKlTg(3qC)Bb32?zh&zyJUM literal 0 HcmV?d00001 diff --git a/packages/client-generator/python-runtime/__pycache__/_send.cpython-314.pyc b/packages/client-generator/python-runtime/__pycache__/_send.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bf2bb376ad7fb1ea96125b5e549d9cf4eca22275 GIT binary patch literal 6754 zcmc&ZTWlLwc6a!Gi5xyeQZGxgEXlMb(^)%-EjdaQOOBp4JsQz3S`1B&B-#|o%#3Wy zT?G0l7IwCaD7)KQi!M+FSRl%x4`H#u%KeG3KkIh0&B&ye^u&M{P5Y5Q8g`n%Up@B@ zhoTj^X^R5A;GBEz%zd1D?z!j1{q`CQf^z3s`tqh5p)YVlEwqAAPyQZ|MKpyNY8VYs zBGrs>&9Fw)kXkEhNv#ugP-}WE71(ZLu2HxZm>%#4Pyz+;8S22Twoz4KgLqf+0ma( zvdK9vGcOMEe1?xxFbRHeF~OsS=99gEETS-)(j577gc9(s5uK)HDTV@qw2Y3?GX}=U zn1VVW#QdcOy32++PMpmoWUV0bvT>G+B{*KVVaIe_fLC2D04~ZVo)fY_D0joJQgvZg z6tmY4Fy#*yWdqNN^L$#?UCLxqALA_=rZfCpOcXGoyXAAiNde{#&c%3Pmb)rkSy%{O z%kWo%Gf6R+$#UsfHrX9dC4<>m{7P(w6N1p-(qi`vm*)7GnBjxj1)w$E&CjPrU`8-1 zaOuQ>?1F5IM$6pfqEX%h%X#4u4gy$2Un3ngOg$!WIbI_5>Pe-RPE3`N_>9uy35-E; zU|pqPHWBvz91zUjkjnT<7laO%@d)ls%29y5RA;$0W59>ej2INA@RzJMtgU<_2gOir zD6C;LBh{l|D|l2`6VjC@s;VL|r&92YaHQI2j^PA1m(7S=I=;|7$}OD0lBJ)|W;tGV zM~4Rn$3v&s!INxse2^VFH83Gt1rWh`AsWvlIN2Fxnf{X#!??o)+s{r;1a!O==H_cK zumg~FLTs87Ff|<=9W6XuO$6f-+;nU{B}PF6_=RXD%{Re&at0>=Swuyv^ZjGDj{UGN z-*j-(daz&(N$@*7c-OaiIJ7wM$m)3D@D#1?B~h|A6s$v%b!ho|q505e^P&8!Lz~S* zl5U8vg{l581fR#6Mb;BqpH)dZT)>O6F(!(@LQ&Qx)1quuHWZr{IsQgXMfCBV;2mU^ z=~N~r@=m}56z_sck#@ouB8dcGb_hWLSR75%B#0x1%k~nf*W60I@tRUgljoHV%ATvG z5uZ7c#y*fjkVcIvMF}{gM$~{J73mCX4wvz^>bEGQhdH(t26X7*vh1p(Kt^}&TbY;9 z9yzNj8df{*4Qr<~VQPx%BDz~yq6@31a_OpCIjeiY?4YJpN*KckmGI+zZOMA5YCd(| zR?z!lojR%wv7q2u(AHfvpae)PzDZ4J><3BIt8GF_DqvL4H^HT13p2`WrPpPs&%MTuy>)@a&0zp5FtVWlK1gn&*f@mbEjS2r4j^PGsg}<9s@absuzW zhK_tI25oqV?ov$PWGx@NCSYFRJ5;_zw({j6wD4GU5usTEWDz|wx=I?Y+4<1zUv9{` z+a+83Qvu{Y=2({+{JcJNm8N`evi{w+$3Pq9kNPHpa*11lfF9$fTo} zGKmG*7|*0ZBZ-)gDNc}$Sb2jblO4$|Z56%3Ef8iscf%WDAtLP|!iN9SwCNP%1_sUy zR@n*`xdyDU10EV4C*xw=PEsOG0xQd! zOjg#;^C{j2tu2*UWeuM2n%pyQ68v6i{We-FV9j%mqWq5uEZY1fEpGnFR$tO%q+~#5U&%;N6SCBl z%mlTe8oE?NP&=}BlpF-5QB8ZvNl+J}-6c0cJ;?4X)e_W;Y>twTpnha;D%BCR9(n6Z z4FqjOwmqdLg6={F&*Nr{D(Vii1s5|s*!mCw_@xn{Sp6ajf&tyh05eEcTUACEM&e5! zM8_%Cw)6XJP1wSbeynAje!<)og_pIzaDDA}tgnB;`i`)*yuS5?>+96HvvindyhlN; zC(?LtVO6==nzD14#EUyk6jASFzz zv{ZV5LIz6c)`}A}-V}k;Qc9#^3PSkqY%5!1)LFw0M&ALw7(-ag7{6nGTIg*G!-WrP zg8EGT3r^3X&i(BZIDoRwu#-k7Ccq_gMeq$HCF}wPLLDyiwrboyyw~Px{<$KwI_g{e z1AhQGdspol&XPEx^@k|N4#A8mTRVla-Ubx*f~m)@VY%RUEDB*i zZCjbBXN+C-TQ8w)*w)row^>8jG!6bHaL+sq64VHMU`*9LFxOZUYYyAXx5qy2h+vPC zln^)#+Y94(wd^`%eUX6rm368+1}TU_{DP&@1lRLuW$XIql6MQ0PH^alt<+EjX2 z&5nBo=R-ZeNPDHPz3P^c6U7M$b#~UP@`a&hHC3{W)#J7TTT&0R{w`eZN3SR6}b;<1#_ zcR+m)Hxmh1guYosc~ASw%*bMgd`uM3@;U}b%%pFKVVo-y7O zkF}_5Neam{qzTe-j<~oGhm8zBOM;F*_o2}mCCyYW~!H{}5J z#BG&blM{o{<6|VlFfe$ce{zhKX*IERJo!s;^7Z;OPxu_b#*vlQti8EP?!K zj?7HxqxnIqFJn^@apSnL~KRf@==YJo`4WBP*Ar_H*dpAvik_DOVH-(~! zUh25r^+DH94irt!rM{xczGN<%+)GT!YH%19-zwQrQ`_H+{A^_9`?ap_9t zrsa{f_EygWn|JBjA8pN#jmS|4ba-liDl8u>H0swcLugtBnx%!?${WYonTK?=M zDbD3*t`yFu?ww7o^PgP3b9G}b|2ix2S8t9!a@61Yvj+_wE7#Y2u)`%&(cO@DcNUvF ze?0Wqirk$g8ae9wBfT;{|%)9zF1HHv0XM_eOsD z{gtt`&h;aO-qFq8(L(R3&E8W|=whySD(8#lZEwpqPtoDK>G6 zwEv+CHvCb?haIb)RUxMWjp9l5P5-!&7jyZ93ZU zdanP?az&f7WQMWN1gwjH=jwNl_MqSQbf0o*Ki%63@DDX@7{1gD@L!yD81BdKKch7O z`*0v8Yu_4VWo>AZm32dd{R6ULU~p`Z9h8lyLhSIVlM{Ug==J3?gneKB9R^P^_zw)e z#^4(O0hervMyKZ?1&?#!*rxH=5#zB*0loq$Ncd|2kSe06uk46w`vTQ` zj;!!|gt~I5>kDN2ceLkUz5Nn$%bxE^$oZ84XXwAO str: + return provider() if callable(provider) else provider + + +def _is_configured(scheme: Dict[str, Any], auth: Dict[str, Any]) -> bool: + kind = scheme["kind"] + if kind == "apiKey": + return scheme["scheme"] in (auth.get("api_key") or {}) + if kind == "bearer": + return auth.get("bearer") is not None + return auth.get("basic") is not None + + +def resolve_auth( + security: List[List[Dict[str, Any]]], auth: Dict[str, Any] +) -> Tuple[Dict[str, str], Dict[str, str]]: + """Build (headers, query) for one operation's security OR-alternatives from + the client credentials. When no alternative is fully configured, the first + alternative's configured schemes are still sent (the server rejects the + request — same behavior as the TypeScript runtime).""" + alternative = next( + (schemes for schemes in security if all(_is_configured(s, auth) for s in schemes)), + security[0] if security else [], + ) + headers: Dict[str, str] = {} + query: Dict[str, str] = {} + cookies: List[str] = [] + for scheme in alternative: + kind = scheme["kind"] + if kind == "apiKey": + provider = (auth.get("api_key") or {}).get(scheme["scheme"]) + if provider is None: + continue + value = _resolve_token(provider) + location = scheme.get("in", "header") + if location == "header": + headers[scheme["name"]] = value + elif location == "query": + query[scheme["name"]] = value + else: + # Reserved characters (`;`, `=`, space) must not break Cookie syntax. + cookies.append(f"{scheme['name']}={quote(value, safe='')}") + elif kind == "bearer": + provider = auth.get("bearer") + if provider is not None: + headers["Authorization"] = f"Bearer {_resolve_token(provider)}" + else: + basic = auth.get("basic") + if basic is not None: + username, password = basic["username"], basic["password"] + token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii") + headers["Authorization"] = f"Basic {token}" + if cookies: + headers["Cookie"] = "; ".join(cookies) + return headers, query diff --git a/packages/client-generator/python-runtime/_errors.py b/packages/client-generator/python-runtime/_errors.py new file mode 100644 index 0000000000..ce51709206 --- /dev/null +++ b/packages/client-generator/python-runtime/_errors.py @@ -0,0 +1,48 @@ +# Runtime errors and the result-mode envelope for generated Python clients. +# Hand-authored once, embedded into every generated client (see +# scripts/generate-runtime-sources.mjs) — mirror of the TypeScript runtime's +# errors.ts, kept semantically in lockstep. +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Generic, Optional, TypeVar + +T = TypeVar("T") +E = TypeVar("E") + + +class ApiError(Exception): + """Raised (throw mode) for a non-2xx response, carrying the decoded error body.""" + + def __init__(self, url: str, status: int, status_text: str, body: Any) -> None: + super().__init__(f"Request failed with status {status}") + self.url = url + self.status = status + self.status_text = status_text + self.body = body + + +class ApiTimeoutError(Exception): + """Raised when a request attempt exceeds the configured timeout — carries the + context a log line needs (which operation, what budget, which attempt).""" + + def __init__(self, operation_id: str, timeout: float, attempt: int) -> None: + super().__init__( + f'Request "{operation_id}" timed out after {timeout} s (attempt {attempt})' + ) + self.operation_id = operation_id + self.timeout = timeout + self.attempt = attempt + + +@dataclass +class Result(Generic[T, E]): + """Result-mode return shape: exactly one of `data`/`error` is set.""" + + data: Optional[T] + error: Optional[E] + response: Any # httpx.Response + + @property + def ok(self) -> bool: + return self.error is None diff --git a/packages/client-generator/python-runtime/_send.py b/packages/client-generator/python-runtime/_send.py new file mode 100644 index 0000000000..4f278ca7c6 --- /dev/null +++ b/packages/client-generator/python-runtime/_send.py @@ -0,0 +1,129 @@ +# The request core for generated Python clients — mirror of the TypeScript +# runtime's send.ts: default + config + per-call headers, on_request middleware +# BEFORE serialization (mutations are sent), the retry loop (idempotent-methods +# default, Idempotency-Key opt-in makes POST/PATCH safe, Retry-After honored, +# exponential backoff with full jitter, a fresh timeout budget per attempt), and +# the reverse on_response onion. +from __future__ import annotations + +import random +import time +import uuid +from typing import Any, Dict, List, Optional + +import httpx + +from ._errors import ApiTimeoutError + +_IDEMPOTENT_METHODS = {"GET", "HEAD", "PUT", "DELETE", "OPTIONS"} +_TRANSIENT_STATUS = {408, 429, 500, 502, 503, 504} + + +def _default_retry_on(method: str, headers: Dict[str, str], response: Optional[httpx.Response]) -> bool: + safe = method.upper() in _IDEMPOTENT_METHODS or "Idempotency-Key" in headers + if not safe: + return False + return response is None or response.status_code in _TRANSIENT_STATUS + + +def _retry_delay(retry: Dict[str, Any], attempt: int, retry_after: Optional[str]) -> float: + if retry_after: + try: + return float(retry_after) + except ValueError: + pass # HTTP-date form: fall through to backoff + base = float(retry.get("retry_delay", 1.0)) + raw = base if retry.get("retry_strategy") == "fixed" else base * (2 ** (attempt - 1)) + return random.uniform(0, raw) if retry.get("jitter", True) is not False else raw + + +def send( + client: httpx.Client, + config: Dict[str, Any], + op: Dict[str, Any], + url: str, + *, + method: str, + headers: Optional[Dict[str, str]] = None, + params: Optional[Dict[str, Any]] = None, + json_body: Any = None, + content: Any = None, + files: Any = None, + timeout: Optional[float] = None, + idempotency_key: Any = None, + retry: Optional[Dict[str, Any]] = None, +) -> httpx.Response: + merged_retry: Dict[str, Any] = {**(config.get("retry") or {}), **(retry or {})} + effective_timeout = timeout if timeout is not None else config.get("timeout") + merged_headers: Dict[str, str] = {**(config.get("headers") or {}), **(headers or {})} + + # One stable key per LOGICAL call — set before the retry loop so every + # attempt re-sends the same key; a caller-provided header always wins. + key = idempotency_key if idempotency_key is not None else config.get("idempotency_key") + if ( + key not in (None, False) + and method.upper() in ("POST", "PATCH") + and "Idempotency-Key" not in merged_headers + ): + merged_headers["Idempotency-Key"] = ( + key if isinstance(key, str) else key() if callable(key) else str(uuid.uuid4()) + ) + + context = { + "url": url, + "method": method.upper(), + "headers": merged_headers, + "body": json_body, + "operation": op, + } + middleware: List[Any] = config.get("middleware") or [] + for mw in middleware: + on_request = getattr(mw, "on_request", None) or (mw.get("on_request") if isinstance(mw, dict) else None) + if on_request: + on_request(context) + + max_attempts = 1 + int(merged_retry.get("retries", 0)) + retry_on = merged_retry.get("retry_on") or ( + lambda ctx: _default_retry_on(context["method"], context["headers"], ctx.get("response")) + ) + + attempt = 0 + while True: + attempt += 1 + try: + response = client.request( + context["method"], + context["url"], + headers=context["headers"], + params=params, + json=context["body"] if content is None and files is None else None, + content=content, + files=files, + timeout=effective_timeout if effective_timeout is not None else httpx.USE_CLIENT_DEFAULT, + ) + except httpx.TimeoutException: + if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}): + time.sleep(_retry_delay(merged_retry, attempt, None)) + continue + raise ApiTimeoutError(op.get("id", "?"), float(effective_timeout or 0), attempt) from None + except httpx.TransportError: + if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}): + time.sleep(_retry_delay(merged_retry, attempt, None)) + continue + raise + + for mw in reversed(middleware): + on_response = getattr(mw, "on_response", None) or (mw.get("on_response") if isinstance(mw, dict) else None) + if on_response: + replaced = on_response(response, context) + if replaced is not None: + response = replaced + + if ( + not response.is_success + and attempt < max_attempts + and retry_on({"attempt": attempt, "response": response}) + ): + time.sleep(_retry_delay(merged_retry, attempt, response.headers.get("retry-after"))) + continue + return response diff --git a/packages/client-generator/scripts/generate-runtime-sources.mjs b/packages/client-generator/scripts/generate-runtime-sources.mjs index 2060c9ea1e..f8f7243d03 100644 --- a/packages/client-generator/scripts/generate-runtime-sources.mjs +++ b/packages/client-generator/scripts/generate-runtime-sources.mjs @@ -74,6 +74,29 @@ function declaredNames() { return [...names].sort(); } +// The Python runtime (python-runtime/*.py) embeds the same way: hand-authored +// once, stitched into every generated Python client by the python generator. +const PYTHON_MODULES = ['_errors', '_auth', '_send']; +const pythonDir = join(pkgRoot, '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'); + const line = ` '${name}.py': ${toStringLiteral(source)},`; + return line.length <= 100 ? line : ` '${name}.py':\n ${toStringLiteral(source)},`; +}); +writeFileSync( + pythonOut, + [ + '// GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`.', + 'export const PYTHON_RUNTIME_SOURCES = {', + ...pythonEntries, + '} as const;', + '', + 'export type PythonRuntimeModuleName = keyof typeof PYTHON_RUNTIME_SOURCES;', + '', + ].join('\n') +); + const content = [ '// GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`.', 'export const RUNTIME_SOURCES = {', diff --git a/packages/client-generator/src/emitters/python-runtime-sources.ts b/packages/client-generator/src/emitters/python-runtime-sources.ts new file mode 100644 index 0000000000..20919aa489 --- /dev/null +++ b/packages/client-generator/src/emitters/python-runtime-sources.ts @@ -0,0 +1,11 @@ +// GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`. +export const PYTHON_RUNTIME_SOURCES = { + '_errors.py': + '# Runtime errors and the result-mode envelope for generated Python clients.\n# Hand-authored once, embedded into every generated client (see\n# scripts/generate-runtime-sources.mjs) — mirror of the TypeScript runtime\'s\n# errors.ts, kept semantically in lockstep.\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass\nfrom typing import Any, Generic, Optional, TypeVar\n\nT = TypeVar("T")\nE = TypeVar("E")\n\n\nclass ApiError(Exception):\n """Raised (throw mode) for a non-2xx response, carrying the decoded error body."""\n\n def __init__(self, url: str, status: int, status_text: str, body: Any) -> None:\n super().__init__(f"Request failed with status {status}")\n self.url = url\n self.status = status\n self.status_text = status_text\n self.body = body\n\n\nclass ApiTimeoutError(Exception):\n """Raised when a request attempt exceeds the configured timeout — carries the\n context a log line needs (which operation, what budget, which attempt)."""\n\n def __init__(self, operation_id: str, timeout: float, attempt: int) -> None:\n super().__init__(\n f\'Request "{operation_id}" timed out after {timeout} s (attempt {attempt})\'\n )\n self.operation_id = operation_id\n self.timeout = timeout\n self.attempt = attempt\n\n\n@dataclass\nclass Result(Generic[T, E]):\n """Result-mode return shape: exactly one of `data`/`error` is set."""\n\n data: Optional[T]\n error: Optional[E]\n response: Any # httpx.Response\n\n @property\n def ok(self) -> bool:\n return self.error is None\n', + '_auth.py': + '# Auth resolution for generated Python clients — mirror of the TypeScript\n# runtime\'s auth.ts: the first OR-alternative whose schemes are all configured\n# is applied, so "bearer OR apiKey" works with either credential and never\n# sends both. Cookie-borne api keys fold into a single Cookie header.\nfrom __future__ import annotations\n\nimport base64\nfrom typing import Any, Callable, Dict, List, Tuple, Union\nfrom urllib.parse import quote\n\nTokenProvider = Union[str, Callable[[], str]]\n\n\ndef _resolve_token(provider: TokenProvider) -> str:\n return provider() if callable(provider) else provider\n\n\ndef _is_configured(scheme: Dict[str, Any], auth: Dict[str, Any]) -> bool:\n kind = scheme["kind"]\n if kind == "apiKey":\n return scheme["scheme"] in (auth.get("api_key") or {})\n if kind == "bearer":\n return auth.get("bearer") is not None\n return auth.get("basic") is not None\n\n\ndef resolve_auth(\n security: List[List[Dict[str, Any]]], auth: Dict[str, Any]\n) -> Tuple[Dict[str, str], Dict[str, str]]:\n """Build (headers, query) for one operation\'s security OR-alternatives from\n the client credentials. When no alternative is fully configured, the first\n alternative\'s configured schemes are still sent (the server rejects the\n request — same behavior as the TypeScript runtime)."""\n alternative = next(\n (schemes for schemes in security if all(_is_configured(s, auth) for s in schemes)),\n security[0] if security else [],\n )\n headers: Dict[str, str] = {}\n query: Dict[str, str] = {}\n cookies: List[str] = []\n for scheme in alternative:\n kind = scheme["kind"]\n if kind == "apiKey":\n provider = (auth.get("api_key") or {}).get(scheme["scheme"])\n if provider is None:\n continue\n value = _resolve_token(provider)\n location = scheme.get("in", "header")\n if location == "header":\n headers[scheme["name"]] = value\n elif location == "query":\n query[scheme["name"]] = value\n else:\n # Reserved characters (`;`, `=`, space) must not break Cookie syntax.\n cookies.append(f"{scheme[\'name\']}={quote(value, safe=\'\')}")\n elif kind == "bearer":\n provider = auth.get("bearer")\n if provider is not None:\n headers["Authorization"] = f"Bearer {_resolve_token(provider)}"\n else:\n basic = auth.get("basic")\n if basic is not None:\n username, password = basic["username"], basic["password"]\n token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii")\n headers["Authorization"] = f"Basic {token}"\n if cookies:\n headers["Cookie"] = "; ".join(cookies)\n return headers, query\n', + '_send.py': + '# The request core for generated Python clients — mirror of the TypeScript\n# runtime\'s send.ts: default + config + per-call headers, on_request middleware\n# BEFORE serialization (mutations are sent), the retry loop (idempotent-methods\n# default, Idempotency-Key opt-in makes POST/PATCH safe, Retry-After honored,\n# exponential backoff with full jitter, a fresh timeout budget per attempt), and\n# the reverse on_response onion.\nfrom __future__ import annotations\n\nimport random\nimport time\nimport uuid\nfrom typing import Any, Dict, List, Optional\n\nimport httpx\n\nfrom ._errors import ApiTimeoutError\n\n_IDEMPOTENT_METHODS = {"GET", "HEAD", "PUT", "DELETE", "OPTIONS"}\n_TRANSIENT_STATUS = {408, 429, 500, 502, 503, 504}\n\n\ndef _default_retry_on(method: str, headers: Dict[str, str], response: Optional[httpx.Response]) -> bool:\n safe = method.upper() in _IDEMPOTENT_METHODS or "Idempotency-Key" in headers\n if not safe:\n return False\n return response is None or response.status_code in _TRANSIENT_STATUS\n\n\ndef _retry_delay(retry: Dict[str, Any], attempt: int, retry_after: Optional[str]) -> float:\n if retry_after:\n try:\n return float(retry_after)\n except ValueError:\n pass # HTTP-date form: fall through to backoff\n base = float(retry.get("retry_delay", 1.0))\n raw = base if retry.get("retry_strategy") == "fixed" else base * (2 ** (attempt - 1))\n return random.uniform(0, raw) if retry.get("jitter", True) is not False else raw\n\n\ndef send(\n client: httpx.Client,\n config: Dict[str, Any],\n op: Dict[str, Any],\n url: str,\n *,\n method: str,\n headers: Optional[Dict[str, str]] = None,\n params: Optional[Dict[str, Any]] = None,\n json_body: Any = None,\n content: Any = None,\n files: Any = None,\n timeout: Optional[float] = None,\n idempotency_key: Any = None,\n retry: Optional[Dict[str, Any]] = None,\n) -> httpx.Response:\n merged_retry: Dict[str, Any] = {**(config.get("retry") or {}), **(retry or {})}\n effective_timeout = timeout if timeout is not None else config.get("timeout")\n merged_headers: Dict[str, str] = {**(config.get("headers") or {}), **(headers or {})}\n\n # One stable key per LOGICAL call — set before the retry loop so every\n # attempt re-sends the same key; a caller-provided header always wins.\n key = idempotency_key if idempotency_key is not None else config.get("idempotency_key")\n if (\n key not in (None, False)\n and method.upper() in ("POST", "PATCH")\n and "Idempotency-Key" not in merged_headers\n ):\n merged_headers["Idempotency-Key"] = (\n key if isinstance(key, str) else key() if callable(key) else str(uuid.uuid4())\n )\n\n context = {\n "url": url,\n "method": method.upper(),\n "headers": merged_headers,\n "body": json_body,\n "operation": op,\n }\n middleware: List[Any] = config.get("middleware") or []\n for mw in middleware:\n on_request = getattr(mw, "on_request", None) or (mw.get("on_request") if isinstance(mw, dict) else None)\n if on_request:\n on_request(context)\n\n max_attempts = 1 + int(merged_retry.get("retries", 0))\n retry_on = merged_retry.get("retry_on") or (\n lambda ctx: _default_retry_on(context["method"], context["headers"], ctx.get("response"))\n )\n\n attempt = 0\n while True:\n attempt += 1\n try:\n response = client.request(\n context["method"],\n context["url"],\n headers=context["headers"],\n params=params,\n json=context["body"] if content is None and files is None else None,\n content=content,\n files=files,\n timeout=effective_timeout if effective_timeout is not None else httpx.USE_CLIENT_DEFAULT,\n )\n except httpx.TimeoutException:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n time.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise ApiTimeoutError(op.get("id", "?"), float(effective_timeout or 0), attempt) from None\n except httpx.TransportError:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n time.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise\n\n for mw in reversed(middleware):\n on_response = getattr(mw, "on_response", None) or (mw.get("on_response") if isinstance(mw, dict) else None)\n if on_response:\n replaced = on_response(response, context)\n if replaced is not None:\n response = replaced\n\n if (\n not response.is_success\n and attempt < max_attempts\n and retry_on({"attempt": attempt, "response": response})\n ):\n time.sleep(_retry_delay(merged_retry, attempt, response.headers.get("retry-after")))\n continue\n return response\n', +} as const; + +export type PythonRuntimeModuleName = keyof typeof PYTHON_RUNTIME_SOURCES; diff --git a/packages/client-generator/src/generators/__tests__/python-runtime-embed.test.ts b/packages/client-generator/src/generators/__tests__/python-runtime-embed.test.ts new file mode 100644 index 0000000000..7a5104a38c --- /dev/null +++ b/packages/client-generator/src/generators/__tests__/python-runtime-embed.test.ts @@ -0,0 +1,32 @@ +import { spawnSync } from 'node:child_process'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { PYTHON_RUNTIME_SOURCES } from '../../emitters/python-runtime-sources.js'; + +const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const hasPython = spawnSync('python3', ['--version']).status === 0; + +describe('PYTHON_RUNTIME_SOURCES (the embedded Python runtime)', () => { + it('embeds every runtime module with its load-bearing declarations', () => { + expect(PYTHON_RUNTIME_SOURCES['_errors.py']).toContain('class ApiError'); + expect(PYTHON_RUNTIME_SOURCES['_errors.py']).toContain('class ApiTimeoutError'); + expect(PYTHON_RUNTIME_SOURCES['_errors.py']).toContain('class Result'); + expect(PYTHON_RUNTIME_SOURCES['_auth.py']).toContain('def resolve_auth'); + expect(PYTHON_RUNTIME_SOURCES['_send.py']).toContain('def send'); + expect(PYTHON_RUNTIME_SOURCES['_send.py']).toContain('Idempotency-Key'); + }); + + it.skipIf(!hasPython)('the runtime sources are valid Python (py_compile)', () => { + for (const name of Object.keys(PYTHON_RUNTIME_SOURCES)) { + const result = spawnSync( + 'python3', + ['-m', 'py_compile', join(pkgRoot, 'python-runtime', name)], + { + encoding: 'utf-8', + } + ); + expect(result.status, `${name}: ${result.stderr}`).toBe(0); + } + }); +}); From 5f0bac9d458b0805c92ba4fea8b0bd96945594db Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 11:36:08 +0300 Subject: [PATCH 010/211] feat(client-generator): Python model rendering via the neutral toolkit --- .claude/rules/rules-system.md | 173 ------------------ .../authoring/__tests__/code-writer.test.ts | 9 + .../src/authoring/code-writer.ts | 7 +- .../src/generators/__tests__/python.test.ts | 151 +++++++++++++++ .../client-generator/src/generators/python.ts | 165 +++++++++++++++++ 5 files changed, 329 insertions(+), 176 deletions(-) delete mode 100644 .claude/rules/rules-system.md create mode 100644 packages/client-generator/src/generators/__tests__/python.test.ts create mode 100644 packages/client-generator/src/generators/python.ts diff --git a/.claude/rules/rules-system.md b/.claude/rules/rules-system.md deleted file mode 100644 index 049ee5ddd5..0000000000 --- a/.claude/rules/rules-system.md +++ /dev/null @@ -1,173 +0,0 @@ -## Rules System: Walker, Visitors, and Nodes - -This is the most important pattern to understand when working in `packages/core`. - -### Concepts - -Node — a typed object in the parsed API description AST. -Every node has a name that matches its spec concept: `Schema`, `Operation`, `Server`, `Parameter`, `Response`, etc. -The full list of node types per spec is in `packages/core/src/types/`. - -Visitor — an object whose keys are node names. -When the Walker enters or leaves a node of that type, it calls the corresponding visitor hook. -Visitor names mirror node names exactly. -The full visitor type map is in `packages/core/src/visitors.ts`. - -Walker — the engine in `packages/core/src/walk.ts` (`walkDocument`). -It recursively traverses the parsed document, resolves `$ref` references, and invokes registered visitors at each node. - -### Visitor hooks - -Each key in a visitor object can be either a plain function (shorthand for `enter`) or an object with up to three hooks: - -| Hook | When it runs | -| ------------------ | -------------------------------------------------------------------------------- | -| `enter(node, ctx)` | When the Walker first arrives at this node | -| `leave(node, ctx)` | After all child nodes have been visited; all `$ref`s are resolved by this point | -| `skip(node, ctx)` | Called before `enter`; return `true` to skip this visitor entirely for this node | - -### Context object (`ctx`) - -Every visitor hook receives a context object with: - -| Property | Type | Description | -| ------------------ | -------------------- | ------------------------------------------------------- | -| `report(problem)` | function | Emit a lint problem | -| `location` | `Location` | JSON pointer + source of the current node | -| `key` | `string \| number` | Key of this node within its parent | -| `parent` | `any` | Parent node object | -| `resolve(ref)` | function | Resolve a `$ref` to its target node and location | -| `type` | `NormalizedNodeType` | Type descriptor for the current node | -| `specVersion` | `SpecVersion` | For example, `'OAS3_0'`, `'OAS3_1'` | -| `getVisitorData()` | function | Shared data store scoped to the current rule invocation | - -### Rule function signature - -A rule is a factory function that receives rule options and returns a visitor object. -The type depends on the target spec: - -```ts -import type { Oas3Rule } from '../../visitors.js'; - -// Factory receives rule options, returns a visitor -export const MyRule: Oas3Rule = (options) => { - // State can be kept here — it is scoped to one document walk - return { - NodeName(node, ctx) { - /* shorthand enter */ - }, - - OtherNode: { - enter(node, ctx) { - /* ... */ - }, - leave(node, ctx) { - /* ... */ - }, - skip(node, ctx) { - return false; - }, - }, - }; -}; -``` - -Available rule types: `Oas3Rule`, `Oas3_1Rule`, `Oas2Rule`, `Async2Rule`, `Async3Rule`, `ArazzoRule`. - -### Minimal rule example - -```ts -// packages/core/src/rules/oas3/no-server-trailing-slash.ts -import type { Oas3Rule } from '../../visitors.js'; - -export const NoServerTrailingSlash: Oas3Rule = () => { - return { - Server(server, { report, location }) { - if (server.url?.endsWith('/') && server.url !== '/') { - report({ - message: 'Server `url` should not have a trailing slash.', - location: location.child(['url']), - }); - } - }, - }; -}; -``` - -### Stateful rule example (using `enter` + `leave`) - -```ts -// packages/core/src/rules/oas3/no-unused-components.ts -import type { Oas3Rule } from '../../visitors.js'; - -export const NoUnusedComponents: Oas3Rule = () => { - const components = new Map(); - - return { - // Track every $ref resolution — mark the target as used - ref(ref, { type, resolve, key, location }) { - const resolved = resolve(ref); - if (resolved.location) { - components.set(resolved.location.absolutePointer, { - used: true, - name: key.toString(), - location, - }); - } - }, - - // Report unused components only after the entire document has been walked - Root: { - leave(_, { report }) { - components.forEach((info) => { - if (!info.used) { - report({ - message: `Component: "${info.name}" is never used.`, - location: info.location.key(), - }); - } - }); - }, - }, - - NamedSchemas: { - Schema(schema, { location, key }) { - components.set(location.absolutePointer, { used: false, location, name: key.toString() }); - }, - }, - }; -}; -``` - -### Register a new rule - -After creating the rule file, register it in the spec index: - -```ts -// packages/core/src/rules/oas3/index.ts -import { NoMyRule } from './no-my-rule.js'; - -export const Oas3Rules = { - // ...existing rules... - 'no-my-rule': NoMyRule, -}; -``` - -### Configurable rules (Assertions) - -Users can define their own rules in `redocly.yaml` using the built-in `Assertion` system (`packages/core/src/rules/common/assertions/asserts.ts`). -Instead of writing TypeScript, the user declares a subject node type and a set of assertion checks. -Internally, the subject type is converted into a visitor automatically. - -```yaml -rules: - rule/path-exclude-pattern: - subject: - type: Paths # node type → becomes a visitor - assertions: - notPattern: \/wrong -``` - -Prefer implementing actual rule code over adding assertion-based rules when contributing to the core rule set. - ---- diff --git a/packages/client-generator/src/authoring/__tests__/code-writer.test.ts b/packages/client-generator/src/authoring/__tests__/code-writer.test.ts index b063b6bd4a..9cdecbbf6d 100644 --- a/packages/client-generator/src/authoring/__tests__/code-writer.test.ts +++ b/packages/client-generator/src/authoring/__tests__/code-writer.test.ts @@ -11,6 +11,15 @@ describe('CodeWriter', () => { expect(writer.toString()).toBe('class Pet:\n def __init__(self):\n self.name = name\n'); }); + it('block() without a close suits dedent-terminated languages (Python)', () => { + const writer = new CodeWriter(' '); + writer.block('class Pet:', () => { + writer.line('name: str'); + }); + writer.line('PETS = []'); + expect(writer.toString()).toBe('class Pet:\n name: str\nPETS = []\n'); + }); + it('block() wraps open/body/close; blank() emits an empty line without indentation', () => { const writer = new CodeWriter(' '); writer.block( diff --git a/packages/client-generator/src/authoring/code-writer.ts b/packages/client-generator/src/authoring/code-writer.ts index 080f0b28bb..94bde3e10d 100644 --- a/packages/client-generator/src/authoring/code-writer.ts +++ b/packages/client-generator/src/authoring/code-writer.ts @@ -25,11 +25,12 @@ export class CodeWriter { return this; } - /** `open` at the current depth, `body` indented, `close` back at the current depth. */ - block(open: string, body: () => void, close: string): this { + /** `open` at the current depth, `body` indented, `close` back at the current depth. + * Omit `close` for languages whose blocks end by dedent alone (Python, YAML). */ + block(open: string, body: () => void, close?: string): this { this.line(open); this.indent(body); - return this.line(close); + return close === undefined ? this : this.line(close); } toString(): string { diff --git a/packages/client-generator/src/generators/__tests__/python.test.ts b/packages/client-generator/src/generators/__tests__/python.test.ts new file mode 100644 index 0000000000..6a069643e5 --- /dev/null +++ b/packages/client-generator/src/generators/__tests__/python.test.ts @@ -0,0 +1,151 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js'; +import { renderPythonModels } from '../python.js'; + +const hasPython = spawnSync('python3', ['--version']).status === 0; + +/** Assert the rendered source is valid Python (skipped when python3 is absent). */ +function expectCompiles(source: string): void { + if (!hasPython) return; + const dir = mkdtempSync(join(tmpdir(), 'py-render-')); + try { + const file = join(dir, 'models.py'); + writeFileSync(file, source); + const result = spawnSync('python3', ['-m', 'py_compile', file], { encoding: 'utf-8' }); + expect(result.status, result.stderr).toBe(0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +const STRING: SchemaModel = { kind: 'scalar', scalar: 'string' }; +const INT: SchemaModel = { kind: 'scalar', scalar: 'integer' }; + +function model(schemas: Record): ApiModel { + return { + title: 'Cafe', + version: '1.0.0', + services: [], + schemas: Object.entries(schemas).map(([name, schema]) => ({ name, schema })), + securitySchemes: [], + } as unknown as ApiModel; +} + +describe('renderPythonModels', () => { + it('renders an object schema as a dataclass — required fields first, optional with = None', () => { + const out = renderPythonModels( + model({ + Order: { + kind: 'object', + description: 'One placed order.', + properties: [ + { name: 'note', schema: STRING, required: false }, + { name: 'id', schema: STRING, required: true }, + { name: 'quantity', schema: INT, required: true }, + ], + }, + }) + ); + expect(out).toContain('from __future__ import annotations'); + expect(out).toContain('@dataclass\nclass Order:'); + expect(out).toContain('"""One placed order."""'); + // Required (no default) precede optional (= None) — a Python dataclass constraint. + const id = out.indexOf('id: str'); + const note = out.indexOf('note: Optional[str] = None'); + expect(id).toBeGreaterThan(-1); + expect(note).toBeGreaterThan(id); + }); + + it('flattens allOf compositions into one dataclass', () => { + const out = renderPythonModels( + model({ + Base: { + kind: 'object', + properties: [{ name: 'offset', schema: INT, required: false }], + }, + Page: { + kind: 'intersection', + members: [ + { kind: 'ref', name: 'Base' }, + { + kind: 'object', + properties: [ + { name: 'items', schema: { kind: 'array', items: STRING }, required: true }, + ], + }, + ], + }, + }) + ); + expect(out).toContain('@dataclass\nclass Page:'); + expect(out).toContain('items: List[str]'); + expect(out).toContain('offset: Optional[int] = None'); + }); + + it('renders enums with SCREAMING members and unions as aliases with a discriminator table', () => { + const out = renderPythonModels( + model({ + Status: { kind: 'enum', values: ['in-progress', 'done'], scalar: 'string' }, + Cat: { kind: 'object', properties: [] }, + Dog: { kind: 'object', properties: [] }, + Pet: { + kind: 'union', + members: [ + { kind: 'ref', name: 'Cat' }, + { kind: 'ref', name: 'Dog' }, + ], + discriminator: { + propertyName: 'petType', + mapping: [ + { value: 'cat', schemaName: 'Cat' }, + { value: 'dog', schemaName: 'Dog' }, + ], + }, + }, + }) + ); + expect(out).toContain('class Status(str, Enum):'); + expect(out).toContain('IN_PROGRESS = "in-progress"'); + expect(out).toContain('Pet = Union[Cat, Dog]'); + expect(out).toContain('# Discriminated by "petType": cat -> Cat, dog -> Dog'); + expectCompiles(out); + }); + + it('sanitizes reserved-word field names and records the wire mapping', () => { + const out = renderPythonModels( + model({ + Lesson: { + kind: 'object', + properties: [{ name: 'class', schema: STRING, required: true }], + }, + }) + ); + expect(out).toContain('class_: str'); + expect(out).toContain('"class_": "class"'); + expectCompiles(out); + }); + + it('renders nullable and record shapes idiomatically', () => { + const out = renderPythonModels( + model({ + Thing: { + kind: 'object', + properties: [ + { + name: 'tag', + schema: { kind: 'union', members: [STRING, { kind: 'null' }] }, + required: true, + }, + { name: 'meta', schema: { kind: 'record', value: STRING }, required: true }, + ], + }, + }) + ); + expect(out).toContain('tag: Optional[str]'); + expect(out).toContain('meta: Dict[str, str]'); + }); +}); diff --git a/packages/client-generator/src/generators/python.ts b/packages/client-generator/src/generators/python.ts new file mode 100644 index 0000000000..229c9a9bae --- /dev/null +++ b/packages/client-generator/src/generators/python.ts @@ -0,0 +1,165 @@ +// The built-in `python` generator — the first non-TypeScript library entry, +// authored the way the AGENTS.md skill teaches users' agents to author theirs: +// with the language-neutral toolkit only (CodeWriter + schema/naming helpers). +// A guard test pins that this module never imports the TS emitter toolkit. + +import { + CodeWriter, + discriminatorCases, + docText, + enumValues, + flattenAllOf, + identifierFor, + isNullable, + RESERVED_WORDS, + unwrapNullable, +} from '../authoring/index.js'; +import type { ApiModel, PropertyModel, SchemaModel } from '../intermediate-representation/model.js'; + +const PY = RESERVED_WORDS.python; + +/** A named schema's Python class name. */ +function className(name: string): string { + return identifierFor(name, { style: 'pascal', reserved: PY }); +} + +/** A field/parameter name, with the wire name preserved when sanitization renames it. */ +function fieldName(name: string): { python: string; renamed: boolean } { + const python = identifierFor(name, { style: 'snake', reserved: PY }); + return { python, renamed: python !== name }; +} + +/** The Python type annotation for a schema (anonymous complex shapes collapse to Any-ish). */ +export function pythonType(schema: SchemaModel): string { + if (isNullable(schema)) { + return `Optional[${pythonType(unwrapNullable(schema))}]`; + } + switch (schema.kind) { + case 'scalar': + return { string: 'str', integer: 'int', number: 'float', boolean: 'bool' }[schema.scalar]; + case 'array': + return `List[${pythonType(schema.items)}]`; + case 'record': + return `Dict[str, ${pythonType(schema.value)}]`; + case 'ref': + return className(schema.name); + case 'literal': + return `Literal[${JSON.stringify(schema.value)}]`; + case 'enum': + // Anonymous (inline) enums keep the wire scalar; only NAMED enums get classes. + return { string: 'str', integer: 'int', number: 'float', boolean: 'bool' }[schema.scalar]; + case 'union': + return `Union[${schema.members.map(pythonType).join(', ')}]`; + case 'null': + return 'None'; + case 'omit': + // Python has no Omit; the base class is the honest annotation (readOnly + // fields are server-managed and simply absent on requests). + return className(schema.base); + case 'object': + case 'intersection': + case 'unknown': + return 'Any'; + } +} + +function writeDocstring(writer: CodeWriter, description?: string): void { + const lines = docText(description); + if (lines.length === 0) return; + if (lines.length === 1) { + writer.line(`"""${lines[0]}"""`); + return; + } + writer.line(`"""${lines[0]}`); + for (const line of lines.slice(1)) writer.line(line); + writer.line('"""'); +} + +function writeDataclass( + writer: CodeWriter, + name: string, + properties: PropertyModel[], + description?: string +): void { + writer.line('@dataclass'); + writer.block(`class ${className(name)}:`, () => { + writeDocstring(writer, description); + // 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) writer.line('pass'); + for (const property of ordered) { + const { python, renamed } = fieldName(property.name); + if (renamed) fieldMap.push([python, property.name]); + const baseType = pythonType(property.schema); + if (property.required) { + writer.line(`${python}: ${baseType}`); + } else { + const optional = baseType.startsWith('Optional[') ? baseType : `Optional[${baseType}]`; + writer.line(`${python}: ${optional} = None`); + } + } + if (fieldMap.length > 0) { + writer.blank(); + writer.line('# Python field name -> wire (JSON) name, for (de)serialization.'); + const entries = fieldMap.map(([py, wire]) => `"${py}": ${JSON.stringify(wire)}`).join(', '); + writer.line(`_field_map: ClassVar[Dict[str, str]] = {${entries}}`); + } + }); + writer.blank(); + writer.blank(); +} + +/** Render every named schema: Enum classes, dataclasses (allOf flattened), union aliases. */ +export function renderPythonModels(model: ApiModel): string { + const writer = new CodeWriter(' '); + writer.line('from __future__ import annotations'); + writer.blank(); + writer.line('from dataclasses import dataclass'); + writer.line('from enum import Enum'); + writer.line('from typing import Any, ClassVar, Dict, List, Literal, Optional, Union'); + writer.blank(); + writer.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'; + writer.block(`class ${className(name)}(${base}):`, () => { + writeDocstring(writer, schema.description); + asEnum.values.forEach((value, index) => { + writer.line(`${asEnum.memberNames[index]} = ${JSON.stringify(value)}`); + }); + }); + writer.blank(); + writer.blank(); + continue; + } + if (schema.kind === 'object' || schema.kind === 'intersection') { + const flat = flattenAllOf(schema, model); + if (flat !== undefined) { + writeDataclass(writer, name, flat.properties, flat.description ?? schema.description); + 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(', '); + writer.line(`# Discriminated by "${cases.property}": ${table}`); + } + writer.line(`${className(name)} = ${pythonType(schema)}`); + writer.blank(); + }); + } + for (const emit of aliases) emit(); + return writer.toString(); +} From 419deaa5344e331696e8324aa01f26ba69235137 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 11:42:14 +0300 Subject: [PATCH 011/211] feat(client-generator): Python sync/async clients over the descriptor table with reflective hydration --- .../__pycache__/_decode.cpython-314.pyc | Bin 0 -> 4248 bytes .../__pycache__/_send.cpython-314.pyc | Bin 6754 -> 11147 bytes .../__pycache__/_url.cpython-314.pyc | Bin 0 -> 1046 bytes .../python-runtime/_decode.py | 69 +++++ .../client-generator/python-runtime/_send.py | 86 ++++++ .../client-generator/python-runtime/_url.py | 13 + .../scripts/generate-runtime-sources.mjs | 2 +- .../src/emitters/python-runtime-sources.ts | 6 +- .../src/generators/__tests__/python.test.ts | 153 ++++++++++- .../client-generator/src/generators/python.ts | 246 +++++++++++++++++- 10 files changed, 571 insertions(+), 4 deletions(-) create mode 100644 packages/client-generator/python-runtime/__pycache__/_decode.cpython-314.pyc create mode 100644 packages/client-generator/python-runtime/__pycache__/_url.cpython-314.pyc create mode 100644 packages/client-generator/python-runtime/_decode.py create mode 100644 packages/client-generator/python-runtime/_url.py diff --git a/packages/client-generator/python-runtime/__pycache__/_decode.cpython-314.pyc b/packages/client-generator/python-runtime/__pycache__/_decode.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9b7316f8585e5bfffc7b390029031b1b39fc82a3 GIT binary patch literal 4248 zcma)9Z){W76~E8#`ETqzL+rQ-A$edDY9KL?bPEY|N}EIpVIc(KY@J19>=);evvheRTwK_sM3+LzHHt@}_ZjZqU`J#7>F(r?hTVo3Y2bMN!B zO&X?M$@iZ7=iPhGz32RX$9v0N4g}>ElQ_E!(qqywib)rk_0u5CqSI)Q=|w$^%G43r z)N4{rv}IKmTDF(#F{@@e$Mv#37S+;YRjoZX)z)KI?R99->_dYVADVEhjzKHPHY%45 zn*1nW|B4(IV6?J`EGN{6DkWs4ANpLEoSF=98rvaHYqq$kjzn-=(HuldU@0!iS~-!` z>7+O^CdsN2Wnd@rBZY$>3*B`Agjp0qr%kQzAQXeK8gzmgV+NT)(;z!&3KA{2uZYc> znQn&7MCM`s70%J}qja4LbvgKCX%8Y zNlJ~;2`QM2M8_j>Q3*mvl-0(#D2q6vCU7u0t&Sz+Mx2sWX;KW1#KdSKCN?FfHRs4k z{u;!Q5$uKo%i*U~Lp6(@AwT1ON~$^k6VhZ((%w4EqGYy;14GU0_uv}9ct+%j z*&!6B)1*3z;$S8j8>LqgV*Dr!l3qfnC4Z4gQ^*vCUGy?wF{EO96vYmboxiO2Xkt{20sE#V5MH*+t!zz z+Rp6GuZr8qnz|CZo0%!FEXjo!HySdhOid_d>V`{BnoG{_GS2Te^}!>M^HZjznQBm3 zqQQ?}ra{sYt}@nzEQY<{fsNFmq2enT#{n~xf|kSq@u zZN52?5t2|lyB$dkiEt8I z64W43AC&-*XEJYyirOfS0{E)J*mMk_6R4q0n3Aw45NsA2Ul-IdQ7`~ACJazq6_l|^ zQfv~2SfHt2bBv5i;zVp@GLpmuv;tL>GO!h7%>o=E$#Km*B!j+~0H($f zUePQ%&TIBl(CflDfi*`cGLg#5wmxxMALS+_MFj$qqN>JDh_YsuRB=+#tjD0mkqONq zDUz(H5jiRnSxKFVPJls}#I;sNE{FC-MRQ8ZNTH7b4UuLA){Lks23{jpC>jd`8Yf34 z#Q={9Ok;Q-FeJ~imPv8)jEFTmQG(K~#t~8DbV5EyOwh;KadBER(*ey)Ia#w8u1d3v zPth;TbV%dKODaH2sG&h9Etrg9pHWc;{0D&cEP7%=Hs>$S&!uj%m(S0qKK$7lUy&}$ zxqKOO)>S*(y=HY?sk~NmsU~aPo~x|Qq^>5?-D|w}x+fERzdFnNAGu$7Vq#rYIp6lo zXx6uP#l82b9r+rblq0t%T}Hn5{PO2HU){X<@`Y93{$=0(+sz9N7oF*Ak<9bJE^ zzF+;I<{{sIzh{_MW)Pr1(=+4_@nmTNPY zX0q;`Ppzmj_|%5n-isIJ&Rx8)Y^z-><7fN+{Y?;gYp>7TZe6Zu`35nrsxRRkSZM$A zwF9}^p0C3<#~Gy-p8USY(R0A`G4JW+xlbB>y&lV_2P%7==D(IPP`{AkksJYVl36Dt z_OcW&2>29WG2t#uKwOVh8nq0jt_7ZjcIrMyu#0A0glQGvP1yDs+QMvkN~$?OZ7?t{ zqKZIE3kfSw1kx=~X|^QU7$7QcD5V5rzZkQb)fUuz$dO<41=_zC%ykD5a zE-fL>p!eY&1LVp!l+dQTZa!+9MTzJ{o`@iC50~Cpfo_b|#*M|`wMoh`P)Ht&s%+Tf zrGy*cy2LjofDTzK}F>8%}t|IQu(Vf_G9a|99eNhVx%c{7!|;n`!Yv;>XeC z73exow_Jg4VFu{dG}BW`o`hN{&%S!}hK#ioA_x5oPkn2a{)|+@(h-xd2RJ8WTVWSoF~Y zAZu(QrQ&8XMN&N61eHP}MJEtFh`&!}NJWE~qr~yAjUah@=8yFcJgdR(m0&l(n)yym*( z%7`DF{q5O>`a63T_hj9#Wu4yxcBu?L0g$UmJ94F9zQ%ig^Y%p@z)LXxQJ^Wy2N%8b z9$*>us&mo%IUiiM1z-HOx%H#=rS^MA9`YSaM{Y>-QA#GAm%}UW9ZN@6_>N^;$6A?t z_So~Ut;oIq`D4CAAtC(9K~ERYJviv=@>l{+$YVxEQ)&v6|C)ebNPjaDB^oEfzi<6b zNQ}`mhUPakhtSEHUNk{ciiq^`HQV9IL@YHSzK+{rf?{SvwU YhP;1AhqLJLw-)#x`|KD4@Je_2FZdTT(EtDd literal 0 HcmV?d00001 diff --git a/packages/client-generator/python-runtime/__pycache__/_send.cpython-314.pyc b/packages/client-generator/python-runtime/__pycache__/_send.cpython-314.pyc index bf2bb376ad7fb1ea96125b5e549d9cf4eca22275..5d331230b2ea10cfa6430521a3895bd0aeafc698 100644 GIT binary patch delta 2353 zcmbW2U2GIp6vywK*{|81nf?Bx+udbLVd<7dzDp@)rCRb*kbdaD7Zi4_->0R3r5JlgqPvg;uL>?IoJnYHP9U_7;ckXpwZO z#i=_J1nfeqOLz5B&C)8jD7um$tBDpYQ(c8I1fyHChKXhilVOkU(d?idruLqk%u(OT z$+X`OwgB3O1>LU+X_5(Fm>Z-td@(N^$KkT*q_q0tHOo2y2CMxnrFZ#Tj%wh?jy1sF z90_1lT1_(-cS@~N^_0k5kP1@}Dqu=ranyCF`2}B-bq}a{KJUxYDW3Fi_1$%`qRz(a z3xj%*O$i-4rudkD7H&2N#v8Z6;yIywgf*j(%Y~zT1zj(e$Mg!@0VbA0U?A3wD;%si zHeBd04zM|js;YK_R;O|;e6VHRFOahLP+eD)@a?9jRIc|tJ z{aTJ0RuqPfNMXZnN(XT&hGa^aC>}#GrApLV!);0rQI;8AQ~HRgKkS4Q zVoON^GRcu1+C_O@^;62*#GIO?$E`^{cFoCia-6y#$a+!M1b#xT z;Zt&hBHwe}PQm)K{1{buUfD=hKCUFFo1ak{QjTm4gD+gWW19VexrNUwDX;WbtLbf- znu~ZgIu@RL6wcT|k&vVGnAm1@^8IR744N-;KnecsC4N==;(eG`cc?qC$p=}&CWl!t zMKo`BNb~VS?jYU43!XLneRo|$=-$)oz&&2~<`0B~FTS1b$`jHZP7^z6`-V%N^!n$} zcjA65xc}E!nvfIj`CGo=o($w5_S!n(ALGG)zL27wIKgimW1i*jc+#R@3-DP_BNe#Y zn_j>3|Hhs8uW^N4^@I$O5@{6Uq(mX(JL|;~%|s8kKqB2k#bg1`Y_eQpY?LKO2a}cJ z$iRwB^4nvtCM$({PoL(vyM-S_{(g0M7QQch}=^uxs<-&+w>aVQgA9-u48GJUv z9zkG{F$)aajtc7vPx;sc!>Ftk0lAZk0H{62{Ag$g%vE9dKJ%ScZ5bZzFAP_*JmBA!7EA_fy?5k_Wwxe$ zHq>#$({bI?!8b}B4;G?(;nb}5Q zxU_N!@Ukt3+?=<<@CorF1Nl&!O0KM=ZL;M`odtC+1vftR<~k>u=x0_ZOg;;Qk=Y3F zb88dusz5s%EmuVewM^l$t7?7cCh_W)8sK!2wx?~=DJ$w)3#^%5nFjrZnn13K0%sh! zawbbVbGDg!TsgA_SI%q@P;Wv_`45qu>A;+`uW#_MewY>e`WTiT_ADZc7(ndjUC|^h z@I%pB8@5h%gufPzFL?nZgN?SJ7)GxNF71_l$ o6^bpksX%10P5DCDrpzvttXb?*RmsIJwY=`O*`*fxrg@D11jO(T*8l(j delta 458 zcmW-bJ4*vW6h`Ony3Zu5W{swaK?HS^i1E>h4+LeSAi5xGlO>pqC`Q6$L?c$(X%(ih zu(A*=#AYkOKOuH$1g-3}un`pRI56isd~=7Hi}lwDD{I9}g6rXXfA`65T1mKX4(3+n zg5s)7bv35BI@4W)8EH}^o>VZISp=`G5OJd{nkIST*;QaM%q~pJQ+kM}_K>{}*6wNO z`oDKH??$vvPwFR5B8)=?pgU|xXCi)N6Cf4p%7lb5rCkFY^B27r`Kym1M~yUc)yTt8 zcxn`kUPq=8{4$LRSO`a=DdAb+{5?LUtfSR=*3N;$H|**7afAkpHikFGA>(AV^XAz@9Y(nZtADI`i_)d$0TMC{q-( zXi@E2<JMrr}ZgA7=6UnlEl`N#On}Ewvi^8 zrazb_L#65Xejps-27Z)LqkooZBUv)9oO?z{ z0p5DZEn1>BwG~^1quIJ`*ovja)v#}wh!|gHJXhhKR3pKq7CNFNP2{=I;Z8Yv)Q+|9 zfpH`St=yLeXQIOWci5}381S+qB5bB#-nH&PzQ`&&JSwr}XsKGYRsz0c6&E@J-VYwXdgla8t zqQfsA5RDG+AzMxF;v*Lpg(?Lz>`!&Ht4Q^b?o0HH>1(@@PSGmW_(xe+p6 zmpjPhc|ulTa(p7I&K%EG_pnJ;8#xwf)8pEl-Z0HIHKeRQUayEKovm-vc>NC5YZvja zy@2Zs_WK{%S|d8E9ykc}^oc~6w8&Xxl3MVnyMmP?*~D4sIeEqn$jEWK0aw7- z4KoJELfk~w8XFc}&tnCt`A(ThecACUEW)^ffq_&i#XHccWufAF1;mT@fgZz?1))ZE z+B$2KJIVg<$-&L!;J4(^3-h_Q)0uqT`>JzV6Y zoi|<@dn!3G{L}3IZuV@NJ*%@@=E=3mZF6Wt8{%D%`evqCCUUvO3alfp1>zwMQ4L+c z$Td(n&Re!ZPoBFl2I~yl8UGhMN-6zo5Sso? OdVY}J0|OQ;4*LhrNath# literal 0 HcmV?d00001 diff --git a/packages/client-generator/python-runtime/_decode.py b/packages/client-generator/python-runtime/_decode.py new file mode 100644 index 0000000000..5f327df531 --- /dev/null +++ b/packages/client-generator/python-runtime/_decode.py @@ -0,0 +1,69 @@ +# Reflective JSON <-> dataclass conversion for generated Python clients. The +# generated models are plain dataclasses; this decoder hydrates parsed JSON into +# them (and encode() mirrors back to wire shape), honoring each class's +# `_field_map` (python name -> wire name) and typing constructs the generator +# emits: Optional/Union, List, Dict, Enum, Literal, Any. +from __future__ import annotations + +import dataclasses +import typing +from enum import Enum +from typing import Any, get_args, get_origin, get_type_hints + + +def decode(type_: Any, data: Any): + """Best-effort hydration: wire data -> the annotated Python shape. Unknown or + mismatched shapes pass through unchanged (the server is the source of truth).""" + if data is None or type_ is Any or type_ is None: + return data + origin = get_origin(type_) + if origin is typing.Union: + for member in get_args(type_): + if member is type(None): + continue + try: + return decode(member, data) + except (TypeError, ValueError, KeyError): + continue + return data + if origin is list: + (item_type,) = get_args(type_) or (Any,) + return [decode(item_type, item) for item in data] + if origin is dict: + args = get_args(type_) + value_type = args[1] if len(args) == 2 else Any + return {key: decode(value_type, value) for key, value in data.items()} + if origin is typing.Literal: + return data + if isinstance(type_, type) and issubclass(type_, Enum): + return type_(data) + if dataclasses.is_dataclass(type_): + hints = get_type_hints(type_) + field_map = getattr(type_, "_field_map", {}) + kwargs = {} + for field in dataclasses.fields(type_): + wire = field_map.get(field.name, field.name) + if isinstance(data, dict) and wire in data: + kwargs[field.name] = decode(hints.get(field.name, Any), data[wire]) + return type_(**kwargs) + return data + + +def encode(value: Any): + """Python shape -> wire (JSON) shape; inverse of decode for request bodies.""" + if dataclasses.is_dataclass(value) and not isinstance(value, type): + field_map = getattr(type(value), "_field_map", {}) + out = {} + for field in dataclasses.fields(value): + item = getattr(value, field.name) + if item is None: + continue + out[field_map.get(field.name, field.name)] = encode(item) + return out + if isinstance(value, Enum): + return value.value + if isinstance(value, list): + return [encode(item) for item in value] + if isinstance(value, dict): + return {key: encode(item) for key, item in value.items()} + return value diff --git a/packages/client-generator/python-runtime/_send.py b/packages/client-generator/python-runtime/_send.py index 4f278ca7c6..cdd4105d6f 100644 --- a/packages/client-generator/python-runtime/_send.py +++ b/packages/client-generator/python-runtime/_send.py @@ -6,6 +6,7 @@ # the reverse on_response onion. from __future__ import annotations +import asyncio import random import time import uuid @@ -127,3 +128,88 @@ def send( time.sleep(_retry_delay(merged_retry, attempt, response.headers.get("retry-after"))) continue return response + + +async def send_async( + client: httpx.AsyncClient, + config: Dict[str, Any], + op: Dict[str, Any], + url: str, + *, + method: str, + headers: Optional[Dict[str, str]] = None, + params: Optional[Dict[str, Any]] = None, + json_body: Any = None, + content: Any = None, + files: Any = None, + timeout: Optional[float] = None, + idempotency_key: Any = None, + retry: Optional[Dict[str, Any]] = None, +) -> httpx.Response: + """The async mirror of send() — same retry/timeout/idempotency semantics.""" + merged_retry: Dict[str, Any] = {**(config.get("retry") or {}), **(retry or {})} + effective_timeout = timeout if timeout is not None else config.get("timeout") + merged_headers: Dict[str, str] = {**(config.get("headers") or {}), **(headers or {})} + key = idempotency_key if idempotency_key is not None else config.get("idempotency_key") + if ( + key not in (None, False) + and method.upper() in ("POST", "PATCH") + and "Idempotency-Key" not in merged_headers + ): + merged_headers["Idempotency-Key"] = ( + key if isinstance(key, str) else key() if callable(key) else str(uuid.uuid4()) + ) + context = { + "url": url, + "method": method.upper(), + "headers": merged_headers, + "body": json_body, + "operation": op, + } + middleware: List[Any] = config.get("middleware") or [] + for mw in middleware: + on_request = getattr(mw, "on_request", None) or (mw.get("on_request") if isinstance(mw, dict) else None) + if on_request: + on_request(context) + max_attempts = 1 + int(merged_retry.get("retries", 0)) + retry_on = merged_retry.get("retry_on") or ( + lambda ctx: _default_retry_on(context["method"], context["headers"], ctx.get("response")) + ) + attempt = 0 + while True: + attempt += 1 + try: + response = await client.request( + context["method"], + context["url"], + headers=context["headers"], + params=params, + json=context["body"] if content is None and files is None else None, + content=content, + files=files, + timeout=effective_timeout if effective_timeout is not None else httpx.USE_CLIENT_DEFAULT, + ) + except httpx.TimeoutException: + if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}): + await asyncio.sleep(_retry_delay(merged_retry, attempt, None)) + continue + raise ApiTimeoutError(op.get("id", "?"), float(effective_timeout or 0), attempt) from None + except httpx.TransportError: + if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}): + await asyncio.sleep(_retry_delay(merged_retry, attempt, None)) + continue + raise + for mw in reversed(middleware): + on_response = getattr(mw, "on_response", None) or (mw.get("on_response") if isinstance(mw, dict) else None) + if on_response: + replaced = on_response(response, context) + if replaced is not None: + response = replaced + if ( + not response.is_success + and attempt < max_attempts + and retry_on({"attempt": attempt, "response": response}) + ): + await asyncio.sleep(_retry_delay(merged_retry, attempt, response.headers.get("retry-after"))) + continue + return response diff --git a/packages/client-generator/python-runtime/_url.py b/packages/client-generator/python-runtime/_url.py new file mode 100644 index 0000000000..4e53569c28 --- /dev/null +++ b/packages/client-generator/python-runtime/_url.py @@ -0,0 +1,13 @@ +# URL assembly for generated Python clients — path-parameter substitution with +# percent-encoding, mirroring the TypeScript runtime's url.ts semantics. +from __future__ import annotations + +from typing import Any, Dict +from urllib.parse import quote + + +def build_url(server_url: str, path: str, path_params: Dict[str, Any]) -> str: + filled = path + for name, value in path_params.items(): + filled = filled.replace("{" + name + "}", quote(str(value), safe="")) + return server_url.rstrip("/") + filled diff --git a/packages/client-generator/scripts/generate-runtime-sources.mjs b/packages/client-generator/scripts/generate-runtime-sources.mjs index f8f7243d03..068c219aa6 100644 --- a/packages/client-generator/scripts/generate-runtime-sources.mjs +++ b/packages/client-generator/scripts/generate-runtime-sources.mjs @@ -76,7 +76,7 @@ function declaredNames() { // The Python runtime (python-runtime/*.py) embeds the same way: hand-authored // once, stitched into every generated Python client by the python generator. -const PYTHON_MODULES = ['_errors', '_auth', '_send']; +const PYTHON_MODULES = ['_errors', '_auth', '_url', '_decode', '_send']; const pythonDir = join(pkgRoot, 'python-runtime'); const pythonOut = join(pkgRoot, 'src', 'emitters', 'python-runtime-sources.ts'); const pythonEntries = PYTHON_MODULES.map((name) => { diff --git a/packages/client-generator/src/emitters/python-runtime-sources.ts b/packages/client-generator/src/emitters/python-runtime-sources.ts index 20919aa489..bc02e11d48 100644 --- a/packages/client-generator/src/emitters/python-runtime-sources.ts +++ b/packages/client-generator/src/emitters/python-runtime-sources.ts @@ -4,8 +4,12 @@ export const PYTHON_RUNTIME_SOURCES = { '# Runtime errors and the result-mode envelope for generated Python clients.\n# Hand-authored once, embedded into every generated client (see\n# scripts/generate-runtime-sources.mjs) — mirror of the TypeScript runtime\'s\n# errors.ts, kept semantically in lockstep.\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass\nfrom typing import Any, Generic, Optional, TypeVar\n\nT = TypeVar("T")\nE = TypeVar("E")\n\n\nclass ApiError(Exception):\n """Raised (throw mode) for a non-2xx response, carrying the decoded error body."""\n\n def __init__(self, url: str, status: int, status_text: str, body: Any) -> None:\n super().__init__(f"Request failed with status {status}")\n self.url = url\n self.status = status\n self.status_text = status_text\n self.body = body\n\n\nclass ApiTimeoutError(Exception):\n """Raised when a request attempt exceeds the configured timeout — carries the\n context a log line needs (which operation, what budget, which attempt)."""\n\n def __init__(self, operation_id: str, timeout: float, attempt: int) -> None:\n super().__init__(\n f\'Request "{operation_id}" timed out after {timeout} s (attempt {attempt})\'\n )\n self.operation_id = operation_id\n self.timeout = timeout\n self.attempt = attempt\n\n\n@dataclass\nclass Result(Generic[T, E]):\n """Result-mode return shape: exactly one of `data`/`error` is set."""\n\n data: Optional[T]\n error: Optional[E]\n response: Any # httpx.Response\n\n @property\n def ok(self) -> bool:\n return self.error is None\n', '_auth.py': '# Auth resolution for generated Python clients — mirror of the TypeScript\n# runtime\'s auth.ts: the first OR-alternative whose schemes are all configured\n# is applied, so "bearer OR apiKey" works with either credential and never\n# sends both. Cookie-borne api keys fold into a single Cookie header.\nfrom __future__ import annotations\n\nimport base64\nfrom typing import Any, Callable, Dict, List, Tuple, Union\nfrom urllib.parse import quote\n\nTokenProvider = Union[str, Callable[[], str]]\n\n\ndef _resolve_token(provider: TokenProvider) -> str:\n return provider() if callable(provider) else provider\n\n\ndef _is_configured(scheme: Dict[str, Any], auth: Dict[str, Any]) -> bool:\n kind = scheme["kind"]\n if kind == "apiKey":\n return scheme["scheme"] in (auth.get("api_key") or {})\n if kind == "bearer":\n return auth.get("bearer") is not None\n return auth.get("basic") is not None\n\n\ndef resolve_auth(\n security: List[List[Dict[str, Any]]], auth: Dict[str, Any]\n) -> Tuple[Dict[str, str], Dict[str, str]]:\n """Build (headers, query) for one operation\'s security OR-alternatives from\n the client credentials. When no alternative is fully configured, the first\n alternative\'s configured schemes are still sent (the server rejects the\n request — same behavior as the TypeScript runtime)."""\n alternative = next(\n (schemes for schemes in security if all(_is_configured(s, auth) for s in schemes)),\n security[0] if security else [],\n )\n headers: Dict[str, str] = {}\n query: Dict[str, str] = {}\n cookies: List[str] = []\n for scheme in alternative:\n kind = scheme["kind"]\n if kind == "apiKey":\n provider = (auth.get("api_key") or {}).get(scheme["scheme"])\n if provider is None:\n continue\n value = _resolve_token(provider)\n location = scheme.get("in", "header")\n if location == "header":\n headers[scheme["name"]] = value\n elif location == "query":\n query[scheme["name"]] = value\n else:\n # Reserved characters (`;`, `=`, space) must not break Cookie syntax.\n cookies.append(f"{scheme[\'name\']}={quote(value, safe=\'\')}")\n elif kind == "bearer":\n provider = auth.get("bearer")\n if provider is not None:\n headers["Authorization"] = f"Bearer {_resolve_token(provider)}"\n else:\n basic = auth.get("basic")\n if basic is not None:\n username, password = basic["username"], basic["password"]\n token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii")\n headers["Authorization"] = f"Basic {token}"\n if cookies:\n headers["Cookie"] = "; ".join(cookies)\n return headers, query\n', + '_url.py': + '# URL assembly for generated Python clients — path-parameter substitution with\n# percent-encoding, mirroring the TypeScript runtime\'s url.ts semantics.\nfrom __future__ import annotations\n\nfrom typing import Any, Dict\nfrom urllib.parse import quote\n\n\ndef build_url(server_url: str, path: str, path_params: Dict[str, Any]) -> str:\n filled = path\n for name, value in path_params.items():\n filled = filled.replace("{" + name + "}", quote(str(value), safe=""))\n return server_url.rstrip("/") + filled\n', + '_decode.py': + '# Reflective JSON <-> dataclass conversion for generated Python clients. The\n# generated models are plain dataclasses; this decoder hydrates parsed JSON into\n# them (and encode() mirrors back to wire shape), honoring each class\'s\n# `_field_map` (python name -> wire name) and typing constructs the generator\n# emits: Optional/Union, List, Dict, Enum, Literal, Any.\nfrom __future__ import annotations\n\nimport dataclasses\nimport typing\nfrom enum import Enum\nfrom typing import Any, get_args, get_origin, get_type_hints\n\n\ndef decode(type_: Any, data: Any):\n """Best-effort hydration: wire data -> the annotated Python shape. Unknown or\n mismatched shapes pass through unchanged (the server is the source of truth)."""\n if data is None or type_ is Any or type_ is None:\n return data\n origin = get_origin(type_)\n if origin is typing.Union:\n for member in get_args(type_):\n if member is type(None):\n continue\n try:\n return decode(member, data)\n except (TypeError, ValueError, KeyError):\n continue\n return data\n if origin is list:\n (item_type,) = get_args(type_) or (Any,)\n return [decode(item_type, item) for item in data]\n if origin is dict:\n args = get_args(type_)\n value_type = args[1] if len(args) == 2 else Any\n return {key: decode(value_type, value) for key, value in data.items()}\n if origin is typing.Literal:\n return data\n if isinstance(type_, type) and issubclass(type_, Enum):\n return type_(data)\n if dataclasses.is_dataclass(type_):\n hints = get_type_hints(type_)\n field_map = getattr(type_, "_field_map", {})\n kwargs = {}\n for field in dataclasses.fields(type_):\n wire = field_map.get(field.name, field.name)\n if isinstance(data, dict) and wire in data:\n kwargs[field.name] = decode(hints.get(field.name, Any), data[wire])\n return type_(**kwargs)\n return data\n\n\ndef encode(value: Any):\n """Python shape -> wire (JSON) shape; inverse of decode for request bodies."""\n if dataclasses.is_dataclass(value) and not isinstance(value, type):\n field_map = getattr(type(value), "_field_map", {})\n out = {}\n for field in dataclasses.fields(value):\n item = getattr(value, field.name)\n if item is None:\n continue\n out[field_map.get(field.name, field.name)] = encode(item)\n return out\n if isinstance(value, Enum):\n return value.value\n if isinstance(value, list):\n return [encode(item) for item in value]\n if isinstance(value, dict):\n return {key: encode(item) for key, item in value.items()}\n return value\n', '_send.py': - '# The request core for generated Python clients — mirror of the TypeScript\n# runtime\'s send.ts: default + config + per-call headers, on_request middleware\n# BEFORE serialization (mutations are sent), the retry loop (idempotent-methods\n# default, Idempotency-Key opt-in makes POST/PATCH safe, Retry-After honored,\n# exponential backoff with full jitter, a fresh timeout budget per attempt), and\n# the reverse on_response onion.\nfrom __future__ import annotations\n\nimport random\nimport time\nimport uuid\nfrom typing import Any, Dict, List, Optional\n\nimport httpx\n\nfrom ._errors import ApiTimeoutError\n\n_IDEMPOTENT_METHODS = {"GET", "HEAD", "PUT", "DELETE", "OPTIONS"}\n_TRANSIENT_STATUS = {408, 429, 500, 502, 503, 504}\n\n\ndef _default_retry_on(method: str, headers: Dict[str, str], response: Optional[httpx.Response]) -> bool:\n safe = method.upper() in _IDEMPOTENT_METHODS or "Idempotency-Key" in headers\n if not safe:\n return False\n return response is None or response.status_code in _TRANSIENT_STATUS\n\n\ndef _retry_delay(retry: Dict[str, Any], attempt: int, retry_after: Optional[str]) -> float:\n if retry_after:\n try:\n return float(retry_after)\n except ValueError:\n pass # HTTP-date form: fall through to backoff\n base = float(retry.get("retry_delay", 1.0))\n raw = base if retry.get("retry_strategy") == "fixed" else base * (2 ** (attempt - 1))\n return random.uniform(0, raw) if retry.get("jitter", True) is not False else raw\n\n\ndef send(\n client: httpx.Client,\n config: Dict[str, Any],\n op: Dict[str, Any],\n url: str,\n *,\n method: str,\n headers: Optional[Dict[str, str]] = None,\n params: Optional[Dict[str, Any]] = None,\n json_body: Any = None,\n content: Any = None,\n files: Any = None,\n timeout: Optional[float] = None,\n idempotency_key: Any = None,\n retry: Optional[Dict[str, Any]] = None,\n) -> httpx.Response:\n merged_retry: Dict[str, Any] = {**(config.get("retry") or {}), **(retry or {})}\n effective_timeout = timeout if timeout is not None else config.get("timeout")\n merged_headers: Dict[str, str] = {**(config.get("headers") or {}), **(headers or {})}\n\n # One stable key per LOGICAL call — set before the retry loop so every\n # attempt re-sends the same key; a caller-provided header always wins.\n key = idempotency_key if idempotency_key is not None else config.get("idempotency_key")\n if (\n key not in (None, False)\n and method.upper() in ("POST", "PATCH")\n and "Idempotency-Key" not in merged_headers\n ):\n merged_headers["Idempotency-Key"] = (\n key if isinstance(key, str) else key() if callable(key) else str(uuid.uuid4())\n )\n\n context = {\n "url": url,\n "method": method.upper(),\n "headers": merged_headers,\n "body": json_body,\n "operation": op,\n }\n middleware: List[Any] = config.get("middleware") or []\n for mw in middleware:\n on_request = getattr(mw, "on_request", None) or (mw.get("on_request") if isinstance(mw, dict) else None)\n if on_request:\n on_request(context)\n\n max_attempts = 1 + int(merged_retry.get("retries", 0))\n retry_on = merged_retry.get("retry_on") or (\n lambda ctx: _default_retry_on(context["method"], context["headers"], ctx.get("response"))\n )\n\n attempt = 0\n while True:\n attempt += 1\n try:\n response = client.request(\n context["method"],\n context["url"],\n headers=context["headers"],\n params=params,\n json=context["body"] if content is None and files is None else None,\n content=content,\n files=files,\n timeout=effective_timeout if effective_timeout is not None else httpx.USE_CLIENT_DEFAULT,\n )\n except httpx.TimeoutException:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n time.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise ApiTimeoutError(op.get("id", "?"), float(effective_timeout or 0), attempt) from None\n except httpx.TransportError:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n time.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise\n\n for mw in reversed(middleware):\n on_response = getattr(mw, "on_response", None) or (mw.get("on_response") if isinstance(mw, dict) else None)\n if on_response:\n replaced = on_response(response, context)\n if replaced is not None:\n response = replaced\n\n if (\n not response.is_success\n and attempt < max_attempts\n and retry_on({"attempt": attempt, "response": response})\n ):\n time.sleep(_retry_delay(merged_retry, attempt, response.headers.get("retry-after")))\n continue\n return response\n', + '# The request core for generated Python clients — mirror of the TypeScript\n# runtime\'s send.ts: default + config + per-call headers, on_request middleware\n# BEFORE serialization (mutations are sent), the retry loop (idempotent-methods\n# default, Idempotency-Key opt-in makes POST/PATCH safe, Retry-After honored,\n# exponential backoff with full jitter, a fresh timeout budget per attempt), and\n# the reverse on_response onion.\nfrom __future__ import annotations\n\nimport asyncio\nimport random\nimport time\nimport uuid\nfrom typing import Any, Dict, List, Optional\n\nimport httpx\n\nfrom ._errors import ApiTimeoutError\n\n_IDEMPOTENT_METHODS = {"GET", "HEAD", "PUT", "DELETE", "OPTIONS"}\n_TRANSIENT_STATUS = {408, 429, 500, 502, 503, 504}\n\n\ndef _default_retry_on(method: str, headers: Dict[str, str], response: Optional[httpx.Response]) -> bool:\n safe = method.upper() in _IDEMPOTENT_METHODS or "Idempotency-Key" in headers\n if not safe:\n return False\n return response is None or response.status_code in _TRANSIENT_STATUS\n\n\ndef _retry_delay(retry: Dict[str, Any], attempt: int, retry_after: Optional[str]) -> float:\n if retry_after:\n try:\n return float(retry_after)\n except ValueError:\n pass # HTTP-date form: fall through to backoff\n base = float(retry.get("retry_delay", 1.0))\n raw = base if retry.get("retry_strategy") == "fixed" else base * (2 ** (attempt - 1))\n return random.uniform(0, raw) if retry.get("jitter", True) is not False else raw\n\n\ndef send(\n client: httpx.Client,\n config: Dict[str, Any],\n op: Dict[str, Any],\n url: str,\n *,\n method: str,\n headers: Optional[Dict[str, str]] = None,\n params: Optional[Dict[str, Any]] = None,\n json_body: Any = None,\n content: Any = None,\n files: Any = None,\n timeout: Optional[float] = None,\n idempotency_key: Any = None,\n retry: Optional[Dict[str, Any]] = None,\n) -> httpx.Response:\n merged_retry: Dict[str, Any] = {**(config.get("retry") or {}), **(retry or {})}\n effective_timeout = timeout if timeout is not None else config.get("timeout")\n merged_headers: Dict[str, str] = {**(config.get("headers") or {}), **(headers or {})}\n\n # One stable key per LOGICAL call — set before the retry loop so every\n # attempt re-sends the same key; a caller-provided header always wins.\n key = idempotency_key if idempotency_key is not None else config.get("idempotency_key")\n if (\n key not in (None, False)\n and method.upper() in ("POST", "PATCH")\n and "Idempotency-Key" not in merged_headers\n ):\n merged_headers["Idempotency-Key"] = (\n key if isinstance(key, str) else key() if callable(key) else str(uuid.uuid4())\n )\n\n context = {\n "url": url,\n "method": method.upper(),\n "headers": merged_headers,\n "body": json_body,\n "operation": op,\n }\n middleware: List[Any] = config.get("middleware") or []\n for mw in middleware:\n on_request = getattr(mw, "on_request", None) or (mw.get("on_request") if isinstance(mw, dict) else None)\n if on_request:\n on_request(context)\n\n max_attempts = 1 + int(merged_retry.get("retries", 0))\n retry_on = merged_retry.get("retry_on") or (\n lambda ctx: _default_retry_on(context["method"], context["headers"], ctx.get("response"))\n )\n\n attempt = 0\n while True:\n attempt += 1\n try:\n response = client.request(\n context["method"],\n context["url"],\n headers=context["headers"],\n params=params,\n json=context["body"] if content is None and files is None else None,\n content=content,\n files=files,\n timeout=effective_timeout if effective_timeout is not None else httpx.USE_CLIENT_DEFAULT,\n )\n except httpx.TimeoutException:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n time.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise ApiTimeoutError(op.get("id", "?"), float(effective_timeout or 0), attempt) from None\n except httpx.TransportError:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n time.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise\n\n for mw in reversed(middleware):\n on_response = getattr(mw, "on_response", None) or (mw.get("on_response") if isinstance(mw, dict) else None)\n if on_response:\n replaced = on_response(response, context)\n if replaced is not None:\n response = replaced\n\n if (\n not response.is_success\n and attempt < max_attempts\n and retry_on({"attempt": attempt, "response": response})\n ):\n time.sleep(_retry_delay(merged_retry, attempt, response.headers.get("retry-after")))\n continue\n return response\n\n\nasync def send_async(\n client: httpx.AsyncClient,\n config: Dict[str, Any],\n op: Dict[str, Any],\n url: str,\n *,\n method: str,\n headers: Optional[Dict[str, str]] = None,\n params: Optional[Dict[str, Any]] = None,\n json_body: Any = None,\n content: Any = None,\n files: Any = None,\n timeout: Optional[float] = None,\n idempotency_key: Any = None,\n retry: Optional[Dict[str, Any]] = None,\n) -> httpx.Response:\n """The async mirror of send() — same retry/timeout/idempotency semantics."""\n merged_retry: Dict[str, Any] = {**(config.get("retry") or {}), **(retry or {})}\n effective_timeout = timeout if timeout is not None else config.get("timeout")\n merged_headers: Dict[str, str] = {**(config.get("headers") or {}), **(headers or {})}\n key = idempotency_key if idempotency_key is not None else config.get("idempotency_key")\n if (\n key not in (None, False)\n and method.upper() in ("POST", "PATCH")\n and "Idempotency-Key" not in merged_headers\n ):\n merged_headers["Idempotency-Key"] = (\n key if isinstance(key, str) else key() if callable(key) else str(uuid.uuid4())\n )\n context = {\n "url": url,\n "method": method.upper(),\n "headers": merged_headers,\n "body": json_body,\n "operation": op,\n }\n middleware: List[Any] = config.get("middleware") or []\n for mw in middleware:\n on_request = getattr(mw, "on_request", None) or (mw.get("on_request") if isinstance(mw, dict) else None)\n if on_request:\n on_request(context)\n max_attempts = 1 + int(merged_retry.get("retries", 0))\n retry_on = merged_retry.get("retry_on") or (\n lambda ctx: _default_retry_on(context["method"], context["headers"], ctx.get("response"))\n )\n attempt = 0\n while True:\n attempt += 1\n try:\n response = await client.request(\n context["method"],\n context["url"],\n headers=context["headers"],\n params=params,\n json=context["body"] if content is None and files is None else None,\n content=content,\n files=files,\n timeout=effective_timeout if effective_timeout is not None else httpx.USE_CLIENT_DEFAULT,\n )\n except httpx.TimeoutException:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n await asyncio.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise ApiTimeoutError(op.get("id", "?"), float(effective_timeout or 0), attempt) from None\n except httpx.TransportError:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n await asyncio.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise\n for mw in reversed(middleware):\n on_response = getattr(mw, "on_response", None) or (mw.get("on_response") if isinstance(mw, dict) else None)\n if on_response:\n replaced = on_response(response, context)\n if replaced is not None:\n response = replaced\n if (\n not response.is_success\n and attempt < max_attempts\n and retry_on({"attempt": attempt, "response": response})\n ):\n await asyncio.sleep(_retry_delay(merged_retry, attempt, response.headers.get("retry-after")))\n continue\n return response\n', } as const; export type PythonRuntimeModuleName = keyof typeof PYTHON_RUNTIME_SOURCES; diff --git a/packages/client-generator/src/generators/__tests__/python.test.ts b/packages/client-generator/src/generators/__tests__/python.test.ts index 6a069643e5..2e6325012b 100644 --- a/packages/client-generator/src/generators/__tests__/python.test.ts +++ b/packages/client-generator/src/generators/__tests__/python.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 { renderPythonModels } from '../python.js'; +import { pythonGenerator, renderPythonModels } from '../python.js'; const hasPython = spawnSync('python3', ['--version']).status === 0; @@ -149,3 +149,154 @@ describe('renderPythonModels', () => { expect(out).toContain('meta: Dict[str, str]'); }); }); + +const CAFE: ApiModel = { + title: 'Cafe', + version: '1.0.0', + serverUrl: 'https://api.cafe.example', + services: [ + { + name: 'Orders', + operations: [ + { + name: 'listOrders', + specName: 'listOrders', + method: 'get', + path: '/orders', + tags: ['Orders'], + pathParams: [], + queryParams: [ + { name: 'after', in: 'query', required: false, schema: STRING }, + { name: 'limit', in: 'query', required: false, schema: INT }, + ], + headerParams: [], + cookieParams: [], + security: [['BearerAuth']], + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'ref', name: 'OrderPage' }, + }, + ], + errorResponses: [], + }, + { + name: 'getOrder', + specName: 'getOrder', + method: 'get', + path: '/orders/{orderId}', + tags: ['Orders'], + pathParams: [{ name: 'orderId', in: 'path', required: true, schema: STRING }], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'ref', name: 'Order' }, + }, + ], + errorResponses: [], + }, + { + name: 'createOrder', + specName: 'createOrder', + method: 'post', + path: '/orders', + tags: ['Orders'], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + requestBody: { contentType: 'application/json', schema: { kind: 'ref', name: 'Order' } }, + successResponses: [ + { + status: '201', + contentType: 'application/json', + schema: { kind: 'ref', name: 'Order' }, + }, + ], + errorResponses: [], + }, + ], + }, + ], + schemas: [ + { + name: 'Order', + schema: { + kind: 'object', + properties: [{ name: 'id', schema: STRING, required: true }], + }, + }, + { + name: 'OrderPage', + schema: { + kind: 'object', + properties: [ + { + name: 'items', + schema: { kind: 'array', items: { kind: 'ref', name: 'Order' } }, + required: true, + }, + ], + }, + }, + ], + securitySchemes: [{ key: 'BearerAuth', kind: 'bearer' }], +} as unknown as ApiModel; + +function generate(errorMode: 'throw' | 'result' = 'throw'): string { + const files = pythonGenerator({ + model: CAFE, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: { errorMode }, + }); + expect(files).toHaveLength(1); + expect(files[0].path).toBe('/out/client.py'); + return files[0].content; +} + +describe('pythonGenerator (full client assembly)', () => { + it('renders typed sync methods — kwargs for query params, positional path params, hydrated returns', () => { + const out = generate(); + expect(out).toContain('class Client:'); + expect(out).toContain( + 'def list_orders(self, *, after: Optional[str] = None, limit: Optional[int] = None' + ); + expect(out).toContain(') -> OrderPage:'); + expect(out).toContain('def get_order(self, order_id: str, *'); + expect(out).toContain('def create_order(self, body: Order, *'); + expect(out).toContain('return decode(OrderPage, _safe_json(response))'); + // Wire names survive the snake_case kwargs. + expect(out).toContain('params["after"] = encode(after)'); + }); + + it('embeds the runtime, the descriptor table, and an async mirror', () => { + const out = generate(); + expect(out).toContain('def send('); // embedded runtime + expect(out).toContain('async def send_async('); // async mirror + expect(out).toContain('_OPERATIONS = {'); + expect(out).toContain('"id": "listOrders"'); + expect(out).toContain('class AsyncClient:'); + expect(out).toContain('async def list_orders('); + expect(out).not.toContain('from ._'); // relative imports stitched away + }); + + it('raises ApiError in throw mode; returns Result in result mode', () => { + expect(generate('throw')).toContain('raise ApiError('); + const result = generate('result'); + expect(result).toContain(') -> Result:'); + expect(result).toContain('return Result(data=None, error='); + }); + + it('the assembled file is valid Python', () => { + expectCompiles(generate()); + expectCompiles(generate('result')); + }); +}); diff --git a/packages/client-generator/src/generators/python.ts b/packages/client-generator/src/generators/python.ts index 229c9a9bae..c307e979f5 100644 --- a/packages/client-generator/src/generators/python.ts +++ b/packages/client-generator/src/generators/python.ts @@ -14,7 +14,14 @@ import { RESERVED_WORDS, unwrapNullable, } from '../authoring/index.js'; -import type { ApiModel, PropertyModel, SchemaModel } from '../intermediate-representation/model.js'; +import { PYTHON_RUNTIME_SOURCES } from '../emitters/python-runtime-sources.js'; +import type { + ApiModel, + OperationModel, + PropertyModel, + SchemaModel, +} from '../intermediate-representation/model.js'; +import type { Generator } from './types.js'; const PY = RESERVED_WORDS.python; @@ -163,3 +170,240 @@ export function renderPythonModels(model: ApiModel): string { for (const emit of aliases) emit(); return writer.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; +} + +/** Security specs for the descriptor dict — the wire shape resolve_auth consumes. */ +function securitySpecs(op: OperationModel, model: ApiModel): unknown[][] { + return op.security + .map((alternative) => + alternative.flatMap((key): Array> => { + const scheme = model.securitySchemes.find((s) => s.key === key); + if (scheme === undefined) return []; + if (scheme.kind === 'bearer' || scheme.kind === 'basic') { + return [{ scheme: key, kind: scheme.kind }]; + } + if (scheme.kind === 'apiKeyHeader') { + return [{ scheme: key, kind: 'apiKey', name: scheme.headerName, in: 'header' }]; + } + if (scheme.kind === 'apiKeyQuery') { + return [{ scheme: key, kind: 'apiKey', name: scheme.paramName, in: 'query' }]; + } + return [{ scheme: key, kind: 'apiKey', name: scheme.cookieName, in: 'cookie' }]; + }) + ) + .filter((alternative) => alternative.length > 0); +} + +/** JSON → Python literal (dicts/lists/strings/numbers/bools/None). */ +function pythonLiteral(value: unknown): string { + if (value === null || value === undefined) return 'None'; + if (value === true) return 'True'; + if (value === false) return 'False'; + if (typeof value === 'number') return String(value); + if (typeof value === 'string') return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(pythonLiteral).join(', ')}]`; + const entries = Object.entries(value as Record) + .map(([key, entry]) => `${JSON.stringify(key)}: ${pythonLiteral(entry)}`) + .join(', '); + return `{${entries}}`; +} + +/** Every operation with its collision-free snake_case Python method name. */ +function operationIdents(model: ApiModel): Array<{ op: OperationModel; ident: string }> { + const used = new Set(); + const out: Array<{ op: OperationModel; ident: string }> = []; + for (const service of model.services) { + for (const op of service.operations) { + let ident = identifierFor(op.name, { style: 'snake', reserved: PY }); + let suffix = 2; + while (used.has(ident)) + ident = `${identifierFor(op.name, { style: 'snake', reserved: PY })}_${suffix++}`; + used.add(ident); + out.push({ op, ident }); + } + } + return out; +} + +function writeMethod( + writer: CodeWriter, + op: OperationModel, + ident: string, + errorMode: 'throw' | 'result', + isAsync: boolean +): void { + const pathArgs = op.pathParams.map((param) => ({ + param, + python: identifierFor(param.name, { style: 'snake', reserved: PY }), + })); + const queryArgs = op.queryParams.map((param) => ({ + param, + python: identifierFor(param.name, { style: 'snake', reserved: PY }), + })); + const positional = pathArgs.map(({ param, python }) => `${python}: ${pythonType(param.schema)}`); + const bodyArg = op.requestBody ? [`body: ${pythonType(op.requestBody.schema)}`] : []; + const kwargs = [ + ...queryArgs.map(({ param, python }) => { + const annotation = pythonType(param.schema); + const optional = annotation.startsWith('Optional[') ? annotation : `Optional[${annotation}]`; + return `${python}: ${optional} = None`; + }), + 'headers: Optional[Dict[str, str]] = None', + 'timeout: Optional[float] = None', + 'retry: Optional[Dict[str, Any]] = None', + 'idempotency_key: Any = None', + ]; + const success = successSchema(op); + const returns = + errorMode === 'result' ? 'Result' : success === undefined ? 'None' : pythonType(success); + const prefix = isAsync ? 'async def' : 'def'; + const awaitKw = isAsync ? 'await ' : ''; + const sendFn = isAsync ? 'send_async' : 'send'; + const signature = ['self', ...positional, ...bodyArg, '*', ...kwargs].join(', '); + writer.block(`${prefix} ${ident}(${signature}) -> ${returns}:`, () => { + writeDocstring(writer, op.summary); + writer.line(`op = _OPERATIONS["${ident}"]`); + writer.line('auth_headers, auth_query = resolve_auth(op.get("security") or [], self._auth)'); + writer.line('params: Dict[str, Any] = dict(auth_query)'); + for (const { param, python } of queryArgs) { + writer.block(`if ${python} is not None:`, () => { + writer.line(`params[${JSON.stringify(param.name)}] = encode(${python})`); + }); + } + const pathDict = pathArgs + .map(({ param, python }) => `${JSON.stringify(param.name)}: ${python}`) + .join(', '); + writer.line(`url = build_url(self._server_url, op["path"], {${pathDict}})`); + const bodyKw = op.requestBody ? ', json_body=encode(body)' : ''; + writer.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)}, _safe_json(response))`; + if (errorMode === 'result') { + writer.block('if not response.is_success:', () => { + writer.line('return Result(data=None, error=_safe_json(response), response=response)'); + }); + writer.line(`return Result(data=${decoded}, error=None, response=response)`); + } else { + writer.block('if not response.is_success:', () => { + writer.line( + 'raise ApiError(url, response.status_code, response.reason_phrase, _safe_json(response))' + ); + }); + writer.line(success === undefined ? 'return None' : `return ${decoded}`); + } + }); + writer.blank(); +} + +function writeClientClass( + writer: CodeWriter, + model: ApiModel, + errorMode: 'throw' | 'result', + isAsync: boolean +): void { + const name = isAsync ? 'AsyncClient' : 'Client'; + const httpType = isAsync ? 'httpx.AsyncClient' : 'httpx.Client'; + writer.block(`class ${name}:`, () => { + writeDocstring( + writer, + `${isAsync ? 'Async ' : ''}client for ${model.title} (${model.version}).` + ); + writer.block( + `def __init__(self, server_url: str = ${JSON.stringify(model.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:`, + () => { + writer.line('self._server_url = server_url'); + writer.line('self._auth = auth or {}'); + writer.line('self._config: Dict[str, Any] = {'); + writer.indent(() => { + writer.line('"headers": headers or {},'); + writer.line('"timeout": timeout,'); + writer.line('"retry": retry or {},'); + writer.line('"middleware": middleware or [],'); + writer.line('"idempotency_key": idempotency_key,'); + }); + writer.line('}'); + writer.line(`self._http = http_client or ${httpType}()`); + } + ); + writer.blank(); + for (const { op, ident } of operationIdents(model)) { + writeMethod(writer, op, ident, errorMode, isAsync); + } + }); + writer.blank(); +} + +/** The whole generated file: header, models, embedded runtime, descriptors, clients. */ +export const pythonGenerator: Generator = ({ model, outputPath, emit }) => { + const errorMode = emit.errorMode ?? 'throw'; + const writer = new CodeWriter(' '); + writer.line( + `# Generated by @redocly/client-generator (python) from "${model.title}" ${model.version}.` + ); + writer.line('# Do not edit by hand — regenerate with `redocly generate-client`.'); + writer.line('# Requires Python >= 3.9 and httpx: pip install httpx'); + writer.blank(); + + // Models (with the shared imports header). + writer.line(renderPythonModels(model).trimEnd()); + writer.blank(); + writer.blank(); + + // 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. + writer.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(); + writer.line(stitched); + writer.blank(); + } + writer.blank(); + writer.block('def _safe_json(response: httpx.Response) -> Any:', () => { + writer.block('try:', () => { + writer.line('return response.json()'); + }); + writer.block('except Exception:', () => { + writer.line('return None'); + }); + }); + writer.blank(); + + // The wire-shape descriptor table the runtime routes by. + writer.line('_OPERATIONS = {'); + writer.indent(() => { + for (const { op, ident } of operationIdents(model)) { + const descriptor = { + id: op.specName ?? op.name, + method: op.method.toUpperCase(), + path: op.path, + ...(securitySpecs(op, model).length > 0 ? { security: securitySpecs(op, model) } : {}), + }; + writer.line(`"${ident}": ${pythonLiteral(descriptor)},`); + } + }); + writer.line('}'); + writer.blank(); + writer.blank(); + + writeClientClass(writer, model, errorMode, false); + writeClientClass(writer, model, errorMode, true); + + return [{ path: outputPath.replace(/\.[^.\\/]+$/, '.py'), content: writer.toString() }]; +}; From ae6f1eb40b5e2cce2a59d8000a03544cc5b18738 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 11:51:11 +0300 Subject: [PATCH 012/211] feat(client-generator): Python pagination, SSE, and multipart parity --- .../__pycache__/_multipart.cpython-314.pyc | Bin 0 -> 1261 bytes .../__pycache__/_paginate.cpython-314.pyc | Bin 0 -> 11515 bytes .../__pycache__/_send.cpython-314.pyc | Bin 11147 -> 11271 bytes .../__pycache__/_sse.cpython-314.pyc | Bin 0 -> 7583 bytes .../python-runtime/_multipart.py | 24 ++ .../python-runtime/_paginate.py | 206 ++++++++++++++++++ .../client-generator/python-runtime/_send.py | 8 +- .../client-generator/python-runtime/_sse.py | 161 ++++++++++++++ .../scripts/generate-runtime-sources.mjs | 2 +- .../src/emitters/python-runtime-sources.ts | 8 +- .../src/generators/__tests__/python.test.ts | 68 ++++++ .../client-generator/src/generators/python.ts | 193 +++++++++++++++- 12 files changed, 659 insertions(+), 11 deletions(-) create mode 100644 packages/client-generator/python-runtime/__pycache__/_multipart.cpython-314.pyc create mode 100644 packages/client-generator/python-runtime/__pycache__/_paginate.cpython-314.pyc create mode 100644 packages/client-generator/python-runtime/__pycache__/_sse.cpython-314.pyc create mode 100644 packages/client-generator/python-runtime/_multipart.py create mode 100644 packages/client-generator/python-runtime/_paginate.py create mode 100644 packages/client-generator/python-runtime/_sse.py diff --git a/packages/client-generator/python-runtime/__pycache__/_multipart.cpython-314.pyc b/packages/client-generator/python-runtime/__pycache__/_multipart.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9596e648ce740a805ca76a5fc13c01ef85b92425 GIT binary patch literal 1261 zcmZWo&u<$=6n^9V;ba|$Kt&wL&*HY!G+`^%AW$nLq@>tELS-qAP@Py?@5I^0-d)X% z8*RkF2PA@W@r?)}!66s!95}*%kOqlFqeviC9C}L?pCJpI^Jc!8Z{E8y znzsP$H!ZxkssTJygVZul0Xe&gpa*NT@0yt&?+GAqa$rPcKeWVU85t(y?AQZ0f!+?KM(xmob5KDd0ZwhJL?nZ5j zF(wU~p+}npw=w$vB#$+ltP3q#hylyP+2| zwN;voiY^PbFD4{+6yY5%*!&#wug=dsKVJPwUDaF2Jq`N9q(8n}_a&}{p5juK_P`lCT zwuK+LOuQY%a-`81NJAS9_7ZaD(fK4cJ$M2+Qh2PIK0j66!R%r(Ji}#3gi=76iJk~m z!F%_ho(hPPl?ON?Hbfb8?KZhi-b}Uuv=S+2!G>FiUbOOHVhnC_fGw1lE!B-Qs%F#` zZ({YK6kW@d2f6eg$2s*~^;{ZJGaPs^#>=XhmCtJWB@FxceSqh9M$*0Y(G>lk$3xkW zXDp4l^-7AK_2;2xq>8LuNVr0P6=%TP1sH}ISSemp2}PvNsOyz1hPMN0_=0x0w0!Od zT)07#O5<)UD3`;ka~X4EncZ-?D+H6er_x(*`yAVQJPf4abvqF+nM_ro)VF*_G0GLL zG}iq#9;0p2SQ_`;c9(Lc?4>znOg&kSi11nJu=6;TWXjhFdT^3^xqo5zMsM+WavGbd zncmWIVe;GU{`MX_8o&Ht{PG{;Z+&C!XeYT>`}BLd|HZ+yeU!U;n7jJ(N53rn`tDJA z`LMiv{Mt;vxVwCkFYFon`o6dKo%Q28zfY8Q7I&+EO-%RC?N%S> zp>XAN6h_8&t{>>*ObK7Iq}6Dwcd_hLMys4+uVW)k5l4Q|O6roS6k_UyGTXqC$Fffp zPM*coUngfhefZFgPUv;p^aFMg7gRDQ<#DPJLjJLU6dywFPbfZs;@>d&#Kba5HvbE5 CiWp}A literal 0 HcmV?d00001 diff --git a/packages/client-generator/python-runtime/__pycache__/_paginate.cpython-314.pyc b/packages/client-generator/python-runtime/__pycache__/_paginate.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..48d253ba01ededc7d173d2b79eee75f850dae867 GIT binary patch literal 11515 zcmeHNYj7Lab>79|4G;hUlA!p6NQxvVil9VNrcAvkJ_LymF%Shx5hWTTRwN`4gm#w_ zDc3H?@efE&##C%qh$Bzgi94klcg!})RGnrzRO2L0nsh)yGT<#cc9Z^be~FPjjy3H} z&$){QK#I1KxYOy6-XYJud-uK{=X~c|Y&YfTDF_zjja%!FX-I{!9dC@j~LxK zLXO)cnA~Q;TuzWkzqDXM`!V>qMSn7h7=kn2&|DUaGt1ucTrWAkX> zNsCWGTCjO^@T4d2@;nAe8%a7J%H_b5DOIlEbNg3sHiGOiZWxUnS~f$=mQ>4y@SF?J z*3|PBkD`)t=6!^dbkd^17Ya=ZJ|Qp};`^Oik!cUjh&k>2Olb6oz;Zrek`s09S9}4% zcP_|^+74eZNK(qqz^EW<(}lFh!q|u}D5?gh!a>%_h`O+kBn%!F1{R|Aq`WX*suFSr?%NaR!dLR!+jBZec~=iFNH6T15)j5k6(0@;1X07W;h=An74-o=5Q6t1NGtt#mzgnE5S76I z52XUUKLGbZWZ+7POppyZ)f}z}2UCTsHaf*|a3w^QFnN&;iOfZIMpOktes-G2t6W{} zfWX3#nBsC{)2jr3~@eh!gr9RY{<|!JG`1>k80k`OXO6q9{!&3UhUqwu|EkdRb4y$E1Mn0_Vojz&AygTS!*8@P)6Hq zKeq*@uuhTTiH2RKAJ&q|eW4P?{+0>t7nv;Y2lPh8Y}xwgqh*A zz8X}t(eka6^iw(IqSCip>18|$qV4`Ib$he>g03)tCS$VOPuErzQ<0BNH*FtIiLB4$IIQ#PSU78-BXu5a#v+jRa2H7M>M|O3m>?_69!_h?CPr>A zy$f(CwR&X|m^F1T?dYk!L(k54}cuCo7s&&yXoGs0*ul-i4KAJVsI$&S18F z9a74puu&f61z6!}dREb^(xaAy_3ODE)sduZBZ4^3IN%QTFa zmIQe<}6kY3V@xz-oa0jR)aoXklOGC zulQzohmYd|mszmGDqffYgscYJ$OC9r5jEW}YR7&2$OLrJEgC}Xv@jyQ=ZayTkMcp+ z%Y&5i9X`SFsK^0*11xv=U`$*fH0GG(9HFU+b1e7BomIsj@Jq$~m(h+tO-2Y17GOJ0 zTY|taM>09z1nWrkAzTTd)ei?2OuA>yJ*0spglC2Ts!9IMgWt9!Qo*=CNvEwhQK6I zdmfDLIRO2lHay9bGa#D69D6x1ImP3sp#CAOuZN}szF^=g>*vvMS4(&YF%Jn6@X=@H z!5xQE#KJ1T1bhT$@qwDk$(^%*qi?or)nJ}e-!#q{qZM&O>1=0GW1Q38(-bCi^KTa3 zD2!G`ue`A%o?9DH0~A^B6dHULPR3A52u9#ilKep8Q?$!H8zZ!2o9UJk-oBi)w3RbOK=d0e<-qtRb zE-B)*9dT=Cq$6pzUVk<^I`6p^z#=w)V{dl6WqXT_RW>JVEi1N`)dEMXvh_~hor(K< zdSk79vA(g`*70~jAZ7_b?XN$F1-4#{7X)LLU^2h(dUsN1nydbL`KqmYUWnUvCA7O% zw7WjE$2Z z{n$w57TtO}X0HC2qG2NT^6Tyg45a>(e+tgS3!TO82Kt>++HF(5Q|5&9kF|O3eCo#~ zhg#v`-F%ySJM(U>0@HOgrnfVYUI#Y;W=U|<0SN$GpXWC#T%4lx!Hm9eo|l2c1<1*I`3|I^gEYb-?9Stnam_3ZPKB&H%bC z8v?YzfC~za-NtY>?fPZ4kOo+p(#Vp>J1Y)|O0;86wW#30)WR@C6|gI#Z1yEb5OO z?my<~>S#aS1xN1$DF{v@H^ZI6Gy|>kxN+z#rc@@;_9NIlHv(_SY5N)^W~mPhx!?@0 z=Gtx+ezP!Izo33E*A-E%TC6v<-_*voG%Tv(mfaE62j;x@jd`mk%gcf5fmmV7;?&ND)_@AEo)~WeR-yTksx|T~_@zR~|*>)yv zg*V%7v_&tyvVX<6buEXoHm;c{qa|~!W&Z__3^~?tl3;|Nd-;&GQWMfjLoT3Xuy#Tv z?kI{T6bS#g-6)>qsXYUQ(sWWj?OdZkM8_Xsv=AcE65K?1l7^gppU^d_#G6?L2a0EQDI$%6dK0eBf+bcdmlijdqa@lfi8QRqR7gAcjz|huO4SR^m?|_ z6*gvpY2$F3pq9rg>lQz(c?s1(d5y9~Xr1(=Hd>Rlt;&*@i#}AaTI|X#bOtVlRM8 zK%l{W6?mO19Kb{YzXUm97N}UKNkWEr%SDZ3ka&U)Dv`Y|>T`(I%^FBZk$Y$~WB$$G_8;sCsI->Z$jNTCW`?(C;3= z%Hrz##oOn!t9v?@+`sTA_8ec?b39hrGJg`p!qjqQOYE!XVy7<58R8blhn2O_4v5zb zRPmk%M#}1(>x|{rMteY9SARyAO9 zh*eZH=tK}mX+eaP7DR;7!Wbznh##l4aD0_WY5ff(Hfqcv8nh#f9s}IugJ|h9dU0sy zrgS*yBKh9R@j6;unIUcD_wxNt*?YLjgGwMvnDt~y&mrknBiRgYwVvE&<<*)l-{?4T zS!;O5GsKN~Mkbi+9p~X*3-DpX-#~mDa_jq@D&nz7E?X5$F6)}9Hu_uWQjxkmS{LFA zWjjEU-*gFHk^R71jF;ozEk)7h`OEKF8Y0S7OZmL=FUJ3DeBsI+MZCH#ZrKZ_9T;5p z)*$e}_Y4(Ddr87rvSKWGsG)LQYv4SUe#Rps^1}XhJAGHBLaC?Q^VN3^21tMTBbMT1 z$z)UC!$9K^URLroiPa_!XA6vj*zc>>b>Gc?H{$h0sLiAIil_7-jm#^`q~893A&K%G zsl-#qq{N3R`@s)_DN44B_7mb4=tO-M9!w)0LZ5;c=1tSOo9@dWW%ecO z^Ot543beJK`)8s6mLtR)8=+HcjR38b$rEza??*Ko06i!&43gQU33qu_V1|;Y;e}h8QM5S%jbcQVe?qYtiXKOS zzIr_)4`h4D?--l%aelsm@VtcK<-miY`Z+kaR5u5njpVv;@X4Rz_M^bwB%kISrbbbu zQ480BkDVZ#d0)&{b6r@HxFP7aaDEglit{LllalsNxG~Jb)4;h=96?bF!ntK5fI`Ar zVgm3Ua6Kr<@Om+Yt_e4eB8Vb@;sT0`AVl>93vQL4LwALnKoLUGkK!1L0Tf?B0XLcA zJRtZjAU1N=z`;mC!fpom!H>+sb624Ji&(J~?kHiewg2<%bye*`alEP}QPz?ouwVK= zNML`XJZZCo$5mao(EObX@Ph^5pet(@3~?aFOPjCt6XyM;qpSJl^V)=?aoN$Ba5OJF znisus$KH7Uz6kh&`PZLE?znkxqPcUqxpSp?;8i}_nke4BT)aI|>{>2%-Lp5wyeA{> zRqK{$ZKCKv4F0MQEEUA9ZsMWM7sd+e?wRWdRqcxxoQqk`CGABKcXC@zqueth9P!70vT* z5DV;bMRV+wFE%?pt#XF*dv7-8UFNl|VzBGwnRU302 zUFZa{ShaZN2RoJumz_uBRXvHaof2pJ~tx`hz%eyul4}*N7 z_xw}5@bw_jFVWD&Ybc0Uf-yQJ7^6=DF>y&Ce%vEL{~0_I`r%I?@kRc#i82ERnDS@| z)-U$I-qGCvD1$tHLqfm-)tHD#}uuUa^Nub|~qN~tP-fFk05vitlm zg{5=KWI@qu`iBg)rEE-UZ!}3ZZA>4%0TL7BjnvRT2PkI zka4$+fwYtEcNU5Ik&*LL@Z(DSeIpnIiaQBHR0}gK({bwb2UN>4)dHUtjd^o} z(aI`dD`-vKUv-xQq{3hy?VH!SpeNvQ9J3nm% z=IDWy%H0CrYWew&==2*6u`SMpJ&PyqG%YcAT7NKde|uMKOV@o%_iWCm>Vx3Af7-2p L>q71n8TEeyFTeHf literal 0 HcmV?d00001 diff --git a/packages/client-generator/python-runtime/__pycache__/_send.cpython-314.pyc b/packages/client-generator/python-runtime/__pycache__/_send.cpython-314.pyc index 5d331230b2ea10cfa6430521a3895bd0aeafc698..e508909b4133e7a95e3e86a4205e29ef690a0384 100644 GIT binary patch delta 3534 zcmb`JTWlN06^74{;+y3i}O zQHr8+ks>aTpolX-QJ_JPhay0e0Bxb5uSI|sbz?cIivSh;P!w%m`clwv00(U!dd{p! zNk-kemj(FmKQm``cF%CWnb$5{zu4L)d7OZkuW5PyR5QSzs8GNBhGF;ra-(&^5ng0f zsS`Bi#v8|inyi^R9GYJNEug@HZ^2ac-}oRM-L4N}eQ3Krpqa9eSJ(aQZ&pjwRJmH_ zZhN;gi4Qjeu=_>6#G3h6>}f^P{8~T@YLXVx+*(*uI=s}Azioe0)emF*U%h84)w$)R z^1`K=h2?T}Oz+`?j={AbW7{=&0GAk3?+M*TbD}K`B(=06*s#!~B z&BlH4l-FGHY4(y$bCgUSmgN|qh$;Nr?qtMY^84VKa{!gVsJHR-fac;KNiC*QfWMbY z)9t6u5RZDK-e4(MAJIs`&=9_7Fazge^*2hv(0Kp{Bw?J51{%+l#4}bsX>+J#_@8*< z*;qHzB>a2YE8%gdC^0B0MQqC2(cVw&}#vUzv8Xf4@iaM55! zoHLTRJ7#cZ@Af2lhrOfW`dA|>IsQb-+wJYy&VNN6&DYzD z{`z-g9!K})u^8Y#m-38~m_el+roECmQjG8}Z=fxbfuf3eGa1jSM2pcG@i!WJ0iYPI zs6*pM9*ZjPOeazo=>3aIF?0mKY-&W5!WqzfMMaY_=6&y=MZ$KKf0)kmxG!nJm5QZ) zUwf$D>&Wh2{9C@0X6(W5$cmW_y~Z~(S#FoZ%*#`9f+@UDKJ;&UM%pmHvoHs}<^;^K zysD@0drk{V=roaf4~ar@Y(3f?nsE_}cAz01FunA%cP zTcOBSIJVV({I~7T-cj?Pj-3D9$n2ew*;)Y9miypTrSF{jyub%q$JReplI(;!)DO4% z+s`QGjrI)cZGV>Za0>O4aD=pz>Ni6s)JfvRHY;$wJdEMVM_^bdMu~)dU7oF0E2~co zth~H5Ik+%4y;L=Fn5GCLk>m(yx{E~2NFTW_lH+_Ul1LEI^?njDS2Te-(LjG1g-7i5e6R`x zcTsJ?U04I|!WwWF)_^ z^nIA)O}lCQFS+g59f{mFJr=iR=HO4Kl>Fl}`&w!*v*SL8orbIYci_%O>hQhTfUk_m z_9L1X(mTk$V0lL1+L!5SKzCqw55ClF;QF0R8+#XFeY64UT=Nd7AJr#sHVr+=KWmV6I~~Z+YpE~rk^F3 zA(8mSTwK-hb0M$jZB#|OsD6pW&0o){V-sZYlnZ*9<;oW!(JPK_A`Cv+iWODw|#7Qzx8&Dg?w8R2Hoz+qW>h2BJGZ#Zh9zkvx}WM zXx)60A~$;|apCCYPwWSV3Q<*v2*a%FDmirz#ubYj)5BsP*4dG~=9 zD{Zv;D!+Un;i5TU_*)4ue*M7lHE{r!Am83N^EoO4umSP{PK6 Rh7vXjG!)r;Kq?l(KLAjRsx<%r delta 3264 zcmb`JZEPGz8ONWwH{a{t?(N;)`&-WO`RsEXw{_ygj*Dv&m)hal^>I#GH|gP*#>I{= z>uZ}Jgj1mwB&diP2>}ukMHPV}q*U%l5UNxlv`#Jf6h%}5RRV!TxhR!V6&3M3v*(MG z7!e6py8r%X?wOh0XJ&shUp@BqseHHOa|2$#X3NjKl?8a83R{Kp(R$A z_JXdhT^MrqI&~!sdSLBpr-oid9}L5yylAPbx+MpN@Oq5>)4}7;``iia#tOi0Jj$1C zyZAY0Mh)nyF6lwttA}*0SE6`+$@wkyV}GeuUsyg}U3_AGak*N5(%8ozca5&Tid~$C zn{b@5$aSGty^)5MihuOR8g?=b2e`tGHesFM_BC9>dch|*e8Nf~bu|3K%HZ4GP=pPD ztTt3(gCHr5kgytf{f)4&5qLyOHlo7EK#Mlw!Y07m-bf0Y0!Q$AnyeARC!MjGi31nj zJ@^xE|5_VBHO9}l2bq_DGamN%D(*S_Cjn(Qe?1=M@7Z0pn$!#YckVFvdIp%EUySGY z2XU99CRb!#_^^IT&XBB9GL{D1*ZMY#!w099-Z+wle<}f|0S74UGs+c zkWY%M6?JQjr^Q>0j{{WHEKJ7uSSrnYBJz8_j5Sy>{qKE=Tq7% zCs@(9H6D(xaYc&G#Pqge>{jh$On2N-wr($%9Tn%iB;M3Dj|)11PoX=vM;v83D?7`s ziqy=x#LuO(`~CYdKXlc?(*@H3l$EV4Vn(KK&qz6d%a-v!r70tAnXQaW_@$9ZIdXSK z%8@LT!;`TGEN1VuXgNA>=Ie1BDOwAUp8#AaDU9MTXOihDu_Cl`a3BCM`=}^|vY^Xl zO;<4Hby?+q^Y69$${LoGY=IAFT>LRPWbecN!u$(zPiU+4!1mVsXY!a8Tk%hnu$7I! z#1-WLllZujWEx*k`u}UIXg992Q?LMo)+8*jLc~bpJAKyZM>S{)jeR6xlEL+nfWp`@ zKB@LG2Y*q`tiPsOSs-rUGl6k{vK!B z`igOcLJ?13;3R`lrU<7oS6i&k%*`4TSX_ytbl}C0?@sayAxGd0R?w8Ld>PaI(RpZO z)_)xODPuui4i`NaroL18_Q}ijtC2Kc&1-E6L^^JOi@%beSyv+|c32%h2=5&1DFv(> zomtc?z8q;^8ueWzO4?2Jo3aIUhCaLIr-!TWPT-x~0eA+1SES>M%Bgz2c6QIm%DK}s zql*i3r|V{hG8AE^iJX^&cw%OIh#XEj7>9T~mW&LO6;nX-V$eryJczSqVFi0AW;An-~?= z#Hg?)M#=7s(XRm5Z~V)|Xes{Sju_2ZiJ*^pGW=$|*W<5vnxDIe_a$;x#3-Li9QYpq zx<5$(bs<3AO@N+B_J{uiJozt@h1^$ z?1)MFM#UG6&jH3mB*a0%K;tmEBmDi`bBTw^eum@`loi@cI?fv^RUGBdwrA6q$d*Xx z1Y+R6u^pH?zto;sErgA3s*jN1?$ad76XbB41LH{&+>XFFNwSONDUvG5=SgNzuC-N( zR13?-998Wlp>5P?Cz&UCn&cFT*ccYjtvI(Kx*$J~RRT&sBs~8W_^Y8)>mTnvOo0AW z{w_cV6i5{j&?)IAxFXWm9{#YCU(Em9Im)o~(T72PI#T4`LXyAH`Hj`mA@-B|QQk~? zNDHHYx3{|DsK0Xbk{%qfV}1t25lQw7kMwkRvS`JX~4QMG~SN4D;ah)=eUx!lkc!$(3P$n`gf%?>7FR+ zrjsH!i>%b^*zBRm&HE^FbDxF$0DA83Iw3zti7b-Ll1%fnT^Uy8-|gzyL%W+`oEtV?!Xm@p$|u18-Zi``j;sZ7w N0P3IYbs!b5`EMd-ge?F7 diff --git a/packages/client-generator/python-runtime/__pycache__/_sse.cpython-314.pyc b/packages/client-generator/python-runtime/__pycache__/_sse.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8ea0258c5af6ba4128ae541b67fa96588cd8542d GIT binary patch literal 7583 zcmeHMYit`=cD_Rn$sva?nbiBiktNxtC7FK5??kaEOVPv1n31w;+00lRNwg`FnHj~F zU=+f(Xl)}!l&X@$GKS-R{vm z?Ox5B@5`t8M4RS6<0vMzM6{O@(XrQJj=wiAk5(jlMGi`wr6lD~G1?~4C%Pcb=hFd5 zyCLn#r-P!Uj)eV>uzi>@cqyIEXp)x9q}3isI}?&74W=Yj4ciTCOM2Y!w5a3h!46GU zBrT&D+;J(DlFp@MgKbR?Y6iDjax#nENT~^n%Qcvnk49EA!93{#W|EvC8Y2?WQ?X{P z2mPI4Mavc_5v`g{WFckCr|dLk&!-%s1Ijp>a%!Atg?YFP-ziynOIA+FX)XE|5Cs>= zU~w8f6P|)dr&UckW7uS@VOWxhH6_Wk1|^EDDdQiLu+!k;@w7B5$K!@89v{sl#!|?; zfFf8L>2h9;v| zjxMkvy{u~Ty+hVeAeB^h+Zmh4V@zoYjzRzP>QJmJo+^B zZYprAaV_cN3v;#(0{i-D*9ev%@o?|p0){#$7L|^?=gtM95NW;&?DR~Jv}kg z(%|gYm>c%Inn{OQg=VieMPc4>C~`I>4a$a1&8Cu?;R4g7XzInJHexulz$Vd-8+IuR zb|+ytl4>FeX2`IDsWjO0nPgfC!+3@*m5~xEwJl+fQiURb76_HdaG?c`qk&Wnj@sRL zGGQ7+rz%|p`zfaq28$I8o0(wZhV3mWH3qIllP_shdKIUzWs9&H%){>kIaL{LTzp6Y zbFCbPdi4Bix(AGprH%TKr6Y+dk&bN*fP{$01~ zZ?g;jqdEWadH?Y{tq=VjS6H3*P3?aB&~)&F(2dYcAXnaezr6V~cTlgW`C!M59W#N2 zisq}nE4HZyo%iSX%6YzW`go2P?(>2kET7)|!;ULaJy>?d3B^+<7WqoZy%yCyo2G`| zj_dA{oV$A7T|F&7bk}2M;_WkfY1OrM zDSCx@RzuvypZNkH-#Z7d9-R8_0xwLq>i!@QhkvSV(NQt2-BjM6Sn>tlnYcDFUBBQ9 zU168_iks}Mkww01j_oq-9&Vt#%^`?);H)rDnt+-7db18jU9^i1krSPwD`JD)$t#u6 z#Ng3Kfsa#UY4pob0VA9~jTix)Znggry>t#(rFox1bZ%%Ng2MC zjCev$N#n3NV4G=52x?E17r@5pukjO#0M*tw3F;Oo!Tq7O072idhdBwp0jXZJbJ>c_ z=e|wLHe{CV#2;LCP?jSlmCH`bx`;jWm`B#sUjBSShlHRdsw2eq!~(gSNeIROPrY``!=`Sw4RZH`#505^h5SNP=Z|ypSJrguywAT-FJ7 zzriMhUz-HK=IVd1X~h;p+Btv#g@!)oL5TA<8@yhh^LZ9{wf~fQ5Dsfu!Iy_Ct^E=UHMISmL~lqhCD%dFcY_LxnkYZ$Y2Gcjw{Gl0&9hF@9w#j>#~Q zAt{*xVd$uM`x#S(k1dEkXtBW+rla6mJofx@2`kpsg_3=iC+wRi#i7;cqbRpa!oX>h2XG0=hG$Zr6p6C2sjjSUUSieVcZ z8B1TFu*$Ggv;%PIoTSPs0=g{_Hew=xx6?%kJ~_`qsTuC+FSu%ND^|@>x;Ubbqd>VX>%TrVOg)0$b+; zTW9tx26pJds_WhFcK_hyQbj0N5xHLx(JKz#-j+MmGk>UO?%>I}vR+--mJ@c)3p@4N z@KXm{S+Y#n;*w<-sj8mh^zzCnyI$RptB%Z9M;=!1ngXO$@xzl-ZA%r~XNDeD?A5n4 zeU$o9YH`o;JFhNmIdOxZwl3A~ycJleeQDaRSJp08HtV5=55{kd-w*B8ckchW^QX?6 z9?ZIYieZ(y{7&>x9%@_wvTMu0yBsI`J#rep6)09;+(I3-dF#WFwRZ7yJ!hA z{Ppwx`VZyYmLv09j?DRvJ|z}s$!`Kxy1)3bl?1Dot&si_z=X4Cs^zaeS2#V?2$EgB zd^-+kjB~%^y5{=9ew5>8?T4pkg13rpUAj~Iv&#>5wLS<(?`R7RT?>Kk9M|((H3h5r zz2J!#nd|;&8TqK?VAN*2+fWbmm)@2#h(6gGM0%Km;#sQ&YR=mDXt8tF&tguH!JJ|% z@?{6wYOME)IHVN}(n_pt z?ql<)@De^db$tcjB79d{uf_f_dkzi%u-u6F_BI&8!uqshYXJiFec2N5$j3yEH2$>-OegJyF z)o7r4_Ai6iFMB6G+;TfO#PllPO)ubpr_plpcr-n}k42 z!5gXaDiR94T+#~mQchv+bKsT|!-Ch4^Z_v(RIFr1foDUad{0OV_7j?)|s zan1#(M@{~J6mX9BkHR@2z&WLxZ@!Q#ZG`{D(#Dxf8=y|bW_;~gsMwwhZl4Qo|5k{n zt}$1)f4*-2V%>pf#8nhtMl#*-r^Y^i*Y!9|%9{Q)xc#vo2Ar!quw3%ZFz9b74B7*Y zOvRXVp|tUykUwStdXKPB=N`*IoqIME>mGODgxC5j zhC}LNkn-5WuRN6Zw$wu6(+JbPoBebr2Yi@O4#5<|RvN{IfrqgCVz?N8mKqv^pH1*r z54`Uy=aJxxta*(3fiw7(-|=Ks0gD3n3T-?Kmq_~in1b&>iUSGWbQLEOM0)1Qre_$R zWy~uR-X`c#qd&$_U}QeRa!1VH$Bro(r~*fjsCb5#tqjBbrM>@VB?#~0|bHooA%)&pju#5n=14ejah2%0OpLy}en`cMxwe4AoK{#gOI6C%! E11v@>K>z>% literal 0 HcmV?d00001 diff --git a/packages/client-generator/python-runtime/_multipart.py b/packages/client-generator/python-runtime/_multipart.py new file mode 100644 index 0000000000..e1dedb8cf0 --- /dev/null +++ b/packages/client-generator/python-runtime/_multipart.py @@ -0,0 +1,24 @@ +# Multipart bodies for generated Python clients — a typed dict/dataclass body is +# split into httpx's (data, files): bytes and file-like values upload as parts, +# everything else is form data (nested values JSON-encoded, mirroring the +# TypeScript runtime's FormData serialization). +from __future__ import annotations + +import json +from typing import Any, Dict, Tuple + +from ._decode import encode + + +def to_multipart(body: Any) -> Tuple[Dict[str, Any], Dict[str, Any]]: + wire = encode(body) + data: Dict[str, Any] = {} + files: Dict[str, Any] = {} + for key, value in (wire or {}).items(): + if isinstance(value, (bytes, bytearray)) or hasattr(value, "read"): + files[key] = value + elif isinstance(value, (dict, list)): + data[key] = json.dumps(value) + else: + data[key] = value + return data, files diff --git a/packages/client-generator/python-runtime/_paginate.py b/packages/client-generator/python-runtime/_paginate.py new file mode 100644 index 0000000000..ca0d3cf341 --- /dev/null +++ b/packages/client-generator/python-runtime/_paginate.py @@ -0,0 +1,206 @@ +# Auto-pagination iterators for generated Python clients — the TypeScript +# runtime's paginate.ts semantics ported: cursor (next-cursor pointer, optional +# has-more flag, repeated-cursor guard), offset/page (advance by count/one, +# repeated-page guard, null start treated as absent), and link (RFC 8288 +# `Link: rel="next"` following with relative resolution and a loop guard). +from __future__ import annotations + +import re +from typing import Any, AsyncIterator, Awaitable, Callable, Dict, Iterator, Optional, Tuple +from urllib.parse import parse_qsl, urljoin, urlparse + +# call(params) -> (parsed_json, httpx.Response) +PageCall = Callable[[Dict[str, Any]], Tuple[Any, Any]] + + +def resolve_pointer(data: Any, pointer: str) -> Any: + """RFC 6901 JSON pointer over parsed JSON; None on any miss.""" + if pointer == "": + return data + if not pointer.startswith("/"): + return None + current = data + for token in pointer[1:].split("/"): + key = token.replace("~1", "/").replace("~0", "~") + if isinstance(current, dict): + current = current.get(key) + elif isinstance(current, list) and key.isdigit(): + index = int(key) + current = current[index] if index < len(current) else None + else: + return None + if current is None: + return None + return current + + +def iter_pages(call: PageCall, spec: Dict[str, Any], params: Optional[Dict[str, Any]] = None) -> Iterator[Any]: + """Yield raw page JSON per the pagination spec; every page is yielded before + the stop condition is evaluated, so the last page always arrives.""" + style = spec["style"] + base = dict(params or {}) + if style == "cursor": + cursor = base.get(spec["param"]) + while True: + page_params = dict(base) + if cursor is not None: + page_params[spec["param"]] = cursor + page, _response = call(page_params) + yield page + if spec.get("has_more") is not None and resolve_pointer(page, spec["has_more"]) is False: + return + nxt = resolve_pointer(page, spec.get("next_cursor", "")) + if nxt is None or nxt == "": + return + if not isinstance(nxt, (str, int, float)): + raise ValueError(f"Pagination cursor at {spec['next_cursor']} is not a string or number") + if nxt == cursor: + raise ValueError("Pagination did not advance: the operation returned the same cursor twice") + cursor = nxt + elif style == "link": + yield from _iter_pages_by_link(call, base) + else: # offset / page + start = base.get(spec["param"]) + fallback = 1 if style == "page" else 0 + try: + position = fallback if start in (None, "") else int(start) + except (TypeError, ValueError): + position = fallback + previous_items = None + while True: + page, _response = call({**base, spec["param"]: position}) + items = resolve_pointer(page, spec.get("items", "")) + serialized = repr(items) if isinstance(items, list) else None + if serialized is not None and serialized == previous_items: + raise ValueError("Pagination did not advance: the operation returned the same page twice") + yield page + if not isinstance(items, list) or len(items) == 0: + return + previous_items = serialized + position += 1 if style == "page" else len(items) + + +def _link_next(header: Optional[str]) -> Optional[str]: + if not header: + return None + for entry in re.split(r",\s*(?=<)", header): + match = re.match(r"^\s*<([^>]*)>(.*)$", entry) + if not match: + continue + rel = re.search(r';\s*rel\s*=\s*"?([^";]+)"?', match.group(2), re.IGNORECASE) + if rel and "next" in rel.group(1).split(): + return match.group(1) + return None + + +def _iter_pages_by_link(call: PageCall, base: Dict[str, Any]) -> Iterator[Any]: + params = dict(base) + previous = None + while True: + page, response = call(params) + yield page + target = _link_next(response.headers.get("link")) + if target is None: + return + page_url = str(response.request.url) if response.request is not None else "" + nxt = urljoin(page_url or "http://relative.invalid", target) + if nxt in (previous, page_url): + raise ValueError('Pagination did not advance: the Link rel="next" target repeats') + previous = nxt + link_params: Dict[str, Any] = {} + for key, value in parse_qsl(urlparse(nxt).query): + if key in link_params: + existing = link_params[key] + link_params[key] = [*existing, value] if isinstance(existing, list) else [existing, value] + else: + link_params[key] = value + params = {**base, **link_params} + + +def iter_items(call: PageCall, spec: Dict[str, Any], params: Optional[Dict[str, Any]] = None) -> Iterator[Any]: + """Each page's `items` pointer, flattened.""" + for page in iter_pages(call, spec, params): + items = resolve_pointer(page, spec.get("items", "")) + if isinstance(items, list): + yield from items + + +# call(params) -> awaitable of (parsed_json, httpx.Response) +AsyncPageCall = Callable[[Dict[str, Any]], Awaitable[Tuple[Any, Any]]] + + +async def aiter_pages( + call: AsyncPageCall, spec: Dict[str, Any], params: Optional[Dict[str, Any]] = None +) -> AsyncIterator[Any]: + """Async mirror of iter_pages — same stop conditions and guards.""" + style = spec["style"] + base = dict(params or {}) + if style == "cursor": + cursor = base.get(spec["param"]) + while True: + page_params = dict(base) + if cursor is not None: + page_params[spec["param"]] = cursor + page, _response = await call(page_params) + yield page + if spec.get("has_more") is not None and resolve_pointer(page, spec["has_more"]) is False: + return + nxt = resolve_pointer(page, spec.get("next_cursor", "")) + if nxt is None or nxt == "": + return + if not isinstance(nxt, (str, int, float)): + raise ValueError(f"Pagination cursor at {spec['next_cursor']} is not a string or number") + if nxt == cursor: + raise ValueError("Pagination did not advance: the operation returned the same cursor twice") + cursor = nxt + elif style == "link": + previous = None + link_params: Dict[str, Any] = dict(base) + while True: + page, response = await call(link_params) + yield page + target = _link_next(response.headers.get("link")) + if target is None: + return + page_url = str(response.request.url) if response.request is not None else "" + nxt = urljoin(page_url or "http://relative.invalid", target) + if nxt in (previous, page_url): + raise ValueError('Pagination did not advance: the Link rel="next" target repeats') + previous = nxt + merged: Dict[str, Any] = {} + for key, value in parse_qsl(urlparse(nxt).query): + if key in merged: + existing = merged[key] + merged[key] = [*existing, value] if isinstance(existing, list) else [existing, value] + else: + merged[key] = value + link_params = {**base, **merged} + else: + start = base.get(spec["param"]) + fallback = 1 if style == "page" else 0 + try: + position = fallback if start in (None, "") else int(start) + except (TypeError, ValueError): + position = fallback + previous_items = None + while True: + page, _response = await call({**base, spec["param"]: position}) + items = resolve_pointer(page, spec.get("items", "")) + serialized = repr(items) if isinstance(items, list) else None + if serialized is not None and serialized == previous_items: + raise ValueError("Pagination did not advance: the operation returned the same page twice") + yield page + if not isinstance(items, list) or len(items) == 0: + return + previous_items = serialized + position += 1 if style == "page" else len(items) + + +async def aiter_items( + call: AsyncPageCall, spec: Dict[str, Any], params: Optional[Dict[str, Any]] = None +) -> AsyncIterator[Any]: + async for page in aiter_pages(call, spec, params): + items = resolve_pointer(page, spec.get("items", "")) + if isinstance(items, list): + for item in items: + yield item diff --git a/packages/client-generator/python-runtime/_send.py b/packages/client-generator/python-runtime/_send.py index cdd4105d6f..a46eba6921 100644 --- a/packages/client-generator/python-runtime/_send.py +++ b/packages/client-generator/python-runtime/_send.py @@ -49,6 +49,7 @@ def send( params: Optional[Dict[str, Any]] = None, json_body: Any = None, content: Any = None, + data: Any = None, files: Any = None, timeout: Optional[float] = None, idempotency_key: Any = None, @@ -97,8 +98,9 @@ def send( context["url"], headers=context["headers"], params=params, - json=context["body"] if content is None and files is None else None, + json=context["body"] if content is None and files is None and data is None else None, content=content, + data=data, files=files, timeout=effective_timeout if effective_timeout is not None else httpx.USE_CLIENT_DEFAULT, ) @@ -141,6 +143,7 @@ async def send_async( params: Optional[Dict[str, Any]] = None, json_body: Any = None, content: Any = None, + data: Any = None, files: Any = None, timeout: Optional[float] = None, idempotency_key: Any = None, @@ -184,8 +187,9 @@ async def send_async( context["url"], headers=context["headers"], params=params, - json=context["body"] if content is None and files is None else None, + json=context["body"] if content is None and files is None and data is None else None, content=content, + data=data, files=files, timeout=effective_timeout if effective_timeout is not None else httpx.USE_CLIENT_DEFAULT, ) diff --git a/packages/client-generator/python-runtime/_sse.py b/packages/client-generator/python-runtime/_sse.py new file mode 100644 index 0000000000..66e6c3de5b --- /dev/null +++ b/packages/client-generator/python-runtime/_sse.py @@ -0,0 +1,161 @@ +# Server-Sent Events for generated Python clients — the TypeScript runtime's +# sse.ts semantics ported: frame parsing per the EventSource spec (retry must be +# ASCII digits; comment-only frames skipped; multi-line data joined with \n) and +# auto-reconnect resuming from the last event id via Last-Event-ID, with +# exponential backoff capped at 30s. JSON payloads are parsed when the operation +# declares a JSON event stream. +from __future__ import annotations + +import asyncio +import json +import random +import time +from dataclasses import dataclass +from typing import Any, AsyncIterator, Callable, Dict, Iterator, Optional + +import httpx + +_FRAME_DELIMITER = "\n\n" + + +@dataclass +class ServerSentEvent: + data: Any + event: Optional[str] = None + id: Optional[str] = None + retry: Optional[int] = None + + +def parse_sse_frame(raw: str, data_kind: str = "text") -> Optional[ServerSentEvent]: + event = None + data_lines = [] + event_id = None + retry = None + saw_field = False + for line in raw.replace("\r\n", "\n").replace("\r", "\n").split("\n"): + if line == "" or line.startswith(":"): + continue + field, _, value = line.partition(":") + if value.startswith(" "): + value = value[1:] + saw_field = True + if field == "event": + event = value + elif field == "data": + data_lines.append(value) + elif field == "id": + event_id = value + elif field == "retry" and value.isdigit(): + retry = int(value) + if not saw_field: + return None + text = "\n".join(data_lines) + data: Any = text + if data_kind == "json" and text != "": + data = json.loads(text) + return ServerSentEvent(data=data, event=event, id=event_id, retry=retry) + + +def iter_sse( + open_stream: Callable[[Dict[str, str]], Any], + data_kind: str = "text", + reconnect: bool = True, + reconnect_delay: float = 1.0, +) -> Iterator[ServerSentEvent]: + """Iterate an event stream. `open_stream(extra_headers)` must return an + httpx streaming-response context manager; it is reopened on dropped + connections with Last-Event-ID set (fresh call = fresh auth).""" + last_event_id: Optional[str] = None + server_retry: Optional[float] = None + failures = 0 + while True: + headers = {"Accept": "text/event-stream"} + if last_event_id is not None: + headers["Last-Event-ID"] = last_event_id + try: + with open_stream(headers) as response: + if response.status_code >= 400: + response.read() + raise httpx.HTTPStatusError( + f"SSE request failed with status {response.status_code}", + request=response.request, + response=response, + ) + failures = 0 + buffer = "" + for chunk in response.iter_text(): + buffer += chunk + while _FRAME_DELIMITER in buffer: + raw, buffer = buffer.split(_FRAME_DELIMITER, 1) + parsed = parse_sse_frame(raw, data_kind) + if parsed is not None: + if parsed.id is not None: + last_event_id = parsed.id + if parsed.retry is not None: + server_retry = parsed.retry / 1000 + yield parsed + # Clean end: flush a trailing frame, then finish (no reconnect). + if buffer.strip(): + parsed = parse_sse_frame(buffer, data_kind) + if parsed is not None: + yield parsed + return + except httpx.HTTPStatusError: + raise # a 4xx/5xx is definitive, not a dropped connection + except (httpx.TransportError, httpx.TimeoutException): + if not reconnect: + raise + failures += 1 + base = server_retry if server_retry is not None else reconnect_delay + time.sleep(random.uniform(0, min(base * (2 ** (failures - 1)), 30.0))) + + +async def aiter_sse( + open_stream: Callable[[Dict[str, str]], Any], + data_kind: str = "text", + reconnect: bool = True, + reconnect_delay: float = 1.0, +) -> AsyncIterator[ServerSentEvent]: + """Async mirror of iter_sse; `open_stream` returns an async context manager.""" + last_event_id: Optional[str] = None + server_retry: Optional[float] = None + failures = 0 + while True: + headers = {"Accept": "text/event-stream"} + if last_event_id is not None: + headers["Last-Event-ID"] = last_event_id + try: + async with open_stream(headers) as response: + if response.status_code >= 400: + await response.aread() + raise httpx.HTTPStatusError( + f"SSE request failed with status {response.status_code}", + request=response.request, + response=response, + ) + failures = 0 + buffer = "" + async for chunk in response.aiter_text(): + buffer += chunk + while _FRAME_DELIMITER in buffer: + raw, buffer = buffer.split(_FRAME_DELIMITER, 1) + parsed = parse_sse_frame(raw, data_kind) + if parsed is not None: + if parsed.id is not None: + last_event_id = parsed.id + if parsed.retry is not None: + server_retry = parsed.retry / 1000 + yield parsed + if buffer.strip(): + parsed = parse_sse_frame(buffer, data_kind) + if parsed is not None: + yield parsed + return + except httpx.HTTPStatusError: + raise + except (httpx.TransportError, httpx.TimeoutException): + if not reconnect: + raise + failures += 1 + base = server_retry if server_retry is not None else reconnect_delay + await asyncio.sleep(random.uniform(0, min(base * (2 ** (failures - 1)), 30.0))) diff --git a/packages/client-generator/scripts/generate-runtime-sources.mjs b/packages/client-generator/scripts/generate-runtime-sources.mjs index 068c219aa6..11ef912c12 100644 --- a/packages/client-generator/scripts/generate-runtime-sources.mjs +++ b/packages/client-generator/scripts/generate-runtime-sources.mjs @@ -76,7 +76,7 @@ function declaredNames() { // The Python runtime (python-runtime/*.py) embeds the same way: hand-authored // once, stitched into every generated Python client by the python generator. -const PYTHON_MODULES = ['_errors', '_auth', '_url', '_decode', '_send']; +const PYTHON_MODULES = ['_errors', '_auth', '_url', '_decode', '_send', '_paginate', '_sse', '_multipart']; const pythonDir = join(pkgRoot, 'python-runtime'); const pythonOut = join(pkgRoot, 'src', 'emitters', 'python-runtime-sources.ts'); const pythonEntries = PYTHON_MODULES.map((name) => { diff --git a/packages/client-generator/src/emitters/python-runtime-sources.ts b/packages/client-generator/src/emitters/python-runtime-sources.ts index bc02e11d48..33dea9385d 100644 --- a/packages/client-generator/src/emitters/python-runtime-sources.ts +++ b/packages/client-generator/src/emitters/python-runtime-sources.ts @@ -9,7 +9,13 @@ export const PYTHON_RUNTIME_SOURCES = { '_decode.py': '# Reflective JSON <-> dataclass conversion for generated Python clients. The\n# generated models are plain dataclasses; this decoder hydrates parsed JSON into\n# them (and encode() mirrors back to wire shape), honoring each class\'s\n# `_field_map` (python name -> wire name) and typing constructs the generator\n# emits: Optional/Union, List, Dict, Enum, Literal, Any.\nfrom __future__ import annotations\n\nimport dataclasses\nimport typing\nfrom enum import Enum\nfrom typing import Any, get_args, get_origin, get_type_hints\n\n\ndef decode(type_: Any, data: Any):\n """Best-effort hydration: wire data -> the annotated Python shape. Unknown or\n mismatched shapes pass through unchanged (the server is the source of truth)."""\n if data is None or type_ is Any or type_ is None:\n return data\n origin = get_origin(type_)\n if origin is typing.Union:\n for member in get_args(type_):\n if member is type(None):\n continue\n try:\n return decode(member, data)\n except (TypeError, ValueError, KeyError):\n continue\n return data\n if origin is list:\n (item_type,) = get_args(type_) or (Any,)\n return [decode(item_type, item) for item in data]\n if origin is dict:\n args = get_args(type_)\n value_type = args[1] if len(args) == 2 else Any\n return {key: decode(value_type, value) for key, value in data.items()}\n if origin is typing.Literal:\n return data\n if isinstance(type_, type) and issubclass(type_, Enum):\n return type_(data)\n if dataclasses.is_dataclass(type_):\n hints = get_type_hints(type_)\n field_map = getattr(type_, "_field_map", {})\n kwargs = {}\n for field in dataclasses.fields(type_):\n wire = field_map.get(field.name, field.name)\n if isinstance(data, dict) and wire in data:\n kwargs[field.name] = decode(hints.get(field.name, Any), data[wire])\n return type_(**kwargs)\n return data\n\n\ndef encode(value: Any):\n """Python shape -> wire (JSON) shape; inverse of decode for request bodies."""\n if dataclasses.is_dataclass(value) and not isinstance(value, type):\n field_map = getattr(type(value), "_field_map", {})\n out = {}\n for field in dataclasses.fields(value):\n item = getattr(value, field.name)\n if item is None:\n continue\n out[field_map.get(field.name, field.name)] = encode(item)\n return out\n if isinstance(value, Enum):\n return value.value\n if isinstance(value, list):\n return [encode(item) for item in value]\n if isinstance(value, dict):\n return {key: encode(item) for key, item in value.items()}\n return value\n', '_send.py': - '# The request core for generated Python clients — mirror of the TypeScript\n# runtime\'s send.ts: default + config + per-call headers, on_request middleware\n# BEFORE serialization (mutations are sent), the retry loop (idempotent-methods\n# default, Idempotency-Key opt-in makes POST/PATCH safe, Retry-After honored,\n# exponential backoff with full jitter, a fresh timeout budget per attempt), and\n# the reverse on_response onion.\nfrom __future__ import annotations\n\nimport asyncio\nimport random\nimport time\nimport uuid\nfrom typing import Any, Dict, List, Optional\n\nimport httpx\n\nfrom ._errors import ApiTimeoutError\n\n_IDEMPOTENT_METHODS = {"GET", "HEAD", "PUT", "DELETE", "OPTIONS"}\n_TRANSIENT_STATUS = {408, 429, 500, 502, 503, 504}\n\n\ndef _default_retry_on(method: str, headers: Dict[str, str], response: Optional[httpx.Response]) -> bool:\n safe = method.upper() in _IDEMPOTENT_METHODS or "Idempotency-Key" in headers\n if not safe:\n return False\n return response is None or response.status_code in _TRANSIENT_STATUS\n\n\ndef _retry_delay(retry: Dict[str, Any], attempt: int, retry_after: Optional[str]) -> float:\n if retry_after:\n try:\n return float(retry_after)\n except ValueError:\n pass # HTTP-date form: fall through to backoff\n base = float(retry.get("retry_delay", 1.0))\n raw = base if retry.get("retry_strategy") == "fixed" else base * (2 ** (attempt - 1))\n return random.uniform(0, raw) if retry.get("jitter", True) is not False else raw\n\n\ndef send(\n client: httpx.Client,\n config: Dict[str, Any],\n op: Dict[str, Any],\n url: str,\n *,\n method: str,\n headers: Optional[Dict[str, str]] = None,\n params: Optional[Dict[str, Any]] = None,\n json_body: Any = None,\n content: Any = None,\n files: Any = None,\n timeout: Optional[float] = None,\n idempotency_key: Any = None,\n retry: Optional[Dict[str, Any]] = None,\n) -> httpx.Response:\n merged_retry: Dict[str, Any] = {**(config.get("retry") or {}), **(retry or {})}\n effective_timeout = timeout if timeout is not None else config.get("timeout")\n merged_headers: Dict[str, str] = {**(config.get("headers") or {}), **(headers or {})}\n\n # One stable key per LOGICAL call — set before the retry loop so every\n # attempt re-sends the same key; a caller-provided header always wins.\n key = idempotency_key if idempotency_key is not None else config.get("idempotency_key")\n if (\n key not in (None, False)\n and method.upper() in ("POST", "PATCH")\n and "Idempotency-Key" not in merged_headers\n ):\n merged_headers["Idempotency-Key"] = (\n key if isinstance(key, str) else key() if callable(key) else str(uuid.uuid4())\n )\n\n context = {\n "url": url,\n "method": method.upper(),\n "headers": merged_headers,\n "body": json_body,\n "operation": op,\n }\n middleware: List[Any] = config.get("middleware") or []\n for mw in middleware:\n on_request = getattr(mw, "on_request", None) or (mw.get("on_request") if isinstance(mw, dict) else None)\n if on_request:\n on_request(context)\n\n max_attempts = 1 + int(merged_retry.get("retries", 0))\n retry_on = merged_retry.get("retry_on") or (\n lambda ctx: _default_retry_on(context["method"], context["headers"], ctx.get("response"))\n )\n\n attempt = 0\n while True:\n attempt += 1\n try:\n response = client.request(\n context["method"],\n context["url"],\n headers=context["headers"],\n params=params,\n json=context["body"] if content is None and files is None else None,\n content=content,\n files=files,\n timeout=effective_timeout if effective_timeout is not None else httpx.USE_CLIENT_DEFAULT,\n )\n except httpx.TimeoutException:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n time.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise ApiTimeoutError(op.get("id", "?"), float(effective_timeout or 0), attempt) from None\n except httpx.TransportError:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n time.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise\n\n for mw in reversed(middleware):\n on_response = getattr(mw, "on_response", None) or (mw.get("on_response") if isinstance(mw, dict) else None)\n if on_response:\n replaced = on_response(response, context)\n if replaced is not None:\n response = replaced\n\n if (\n not response.is_success\n and attempt < max_attempts\n and retry_on({"attempt": attempt, "response": response})\n ):\n time.sleep(_retry_delay(merged_retry, attempt, response.headers.get("retry-after")))\n continue\n return response\n\n\nasync def send_async(\n client: httpx.AsyncClient,\n config: Dict[str, Any],\n op: Dict[str, Any],\n url: str,\n *,\n method: str,\n headers: Optional[Dict[str, str]] = None,\n params: Optional[Dict[str, Any]] = None,\n json_body: Any = None,\n content: Any = None,\n files: Any = None,\n timeout: Optional[float] = None,\n idempotency_key: Any = None,\n retry: Optional[Dict[str, Any]] = None,\n) -> httpx.Response:\n """The async mirror of send() — same retry/timeout/idempotency semantics."""\n merged_retry: Dict[str, Any] = {**(config.get("retry") or {}), **(retry or {})}\n effective_timeout = timeout if timeout is not None else config.get("timeout")\n merged_headers: Dict[str, str] = {**(config.get("headers") or {}), **(headers or {})}\n key = idempotency_key if idempotency_key is not None else config.get("idempotency_key")\n if (\n key not in (None, False)\n and method.upper() in ("POST", "PATCH")\n and "Idempotency-Key" not in merged_headers\n ):\n merged_headers["Idempotency-Key"] = (\n key if isinstance(key, str) else key() if callable(key) else str(uuid.uuid4())\n )\n context = {\n "url": url,\n "method": method.upper(),\n "headers": merged_headers,\n "body": json_body,\n "operation": op,\n }\n middleware: List[Any] = config.get("middleware") or []\n for mw in middleware:\n on_request = getattr(mw, "on_request", None) or (mw.get("on_request") if isinstance(mw, dict) else None)\n if on_request:\n on_request(context)\n max_attempts = 1 + int(merged_retry.get("retries", 0))\n retry_on = merged_retry.get("retry_on") or (\n lambda ctx: _default_retry_on(context["method"], context["headers"], ctx.get("response"))\n )\n attempt = 0\n while True:\n attempt += 1\n try:\n response = await client.request(\n context["method"],\n context["url"],\n headers=context["headers"],\n params=params,\n json=context["body"] if content is None and files is None else None,\n content=content,\n files=files,\n timeout=effective_timeout if effective_timeout is not None else httpx.USE_CLIENT_DEFAULT,\n )\n except httpx.TimeoutException:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n await asyncio.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise ApiTimeoutError(op.get("id", "?"), float(effective_timeout or 0), attempt) from None\n except httpx.TransportError:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n await asyncio.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise\n for mw in reversed(middleware):\n on_response = getattr(mw, "on_response", None) or (mw.get("on_response") if isinstance(mw, dict) else None)\n if on_response:\n replaced = on_response(response, context)\n if replaced is not None:\n response = replaced\n if (\n not response.is_success\n and attempt < max_attempts\n and retry_on({"attempt": attempt, "response": response})\n ):\n await asyncio.sleep(_retry_delay(merged_retry, attempt, response.headers.get("retry-after")))\n continue\n return response\n', + '# The request core for generated Python clients — mirror of the TypeScript\n# runtime\'s send.ts: default + config + per-call headers, on_request middleware\n# BEFORE serialization (mutations are sent), the retry loop (idempotent-methods\n# default, Idempotency-Key opt-in makes POST/PATCH safe, Retry-After honored,\n# exponential backoff with full jitter, a fresh timeout budget per attempt), and\n# the reverse on_response onion.\nfrom __future__ import annotations\n\nimport asyncio\nimport random\nimport time\nimport uuid\nfrom typing import Any, Dict, List, Optional\n\nimport httpx\n\nfrom ._errors import ApiTimeoutError\n\n_IDEMPOTENT_METHODS = {"GET", "HEAD", "PUT", "DELETE", "OPTIONS"}\n_TRANSIENT_STATUS = {408, 429, 500, 502, 503, 504}\n\n\ndef _default_retry_on(method: str, headers: Dict[str, str], response: Optional[httpx.Response]) -> bool:\n safe = method.upper() in _IDEMPOTENT_METHODS or "Idempotency-Key" in headers\n if not safe:\n return False\n return response is None or response.status_code in _TRANSIENT_STATUS\n\n\ndef _retry_delay(retry: Dict[str, Any], attempt: int, retry_after: Optional[str]) -> float:\n if retry_after:\n try:\n return float(retry_after)\n except ValueError:\n pass # HTTP-date form: fall through to backoff\n base = float(retry.get("retry_delay", 1.0))\n raw = base if retry.get("retry_strategy") == "fixed" else base * (2 ** (attempt - 1))\n return random.uniform(0, raw) if retry.get("jitter", True) is not False else raw\n\n\ndef send(\n client: httpx.Client,\n config: Dict[str, Any],\n op: Dict[str, Any],\n url: str,\n *,\n method: str,\n headers: Optional[Dict[str, str]] = None,\n params: Optional[Dict[str, Any]] = None,\n json_body: Any = None,\n content: Any = None,\n data: Any = None,\n files: Any = None,\n timeout: Optional[float] = None,\n idempotency_key: Any = None,\n retry: Optional[Dict[str, Any]] = None,\n) -> httpx.Response:\n merged_retry: Dict[str, Any] = {**(config.get("retry") or {}), **(retry or {})}\n effective_timeout = timeout if timeout is not None else config.get("timeout")\n merged_headers: Dict[str, str] = {**(config.get("headers") or {}), **(headers or {})}\n\n # One stable key per LOGICAL call — set before the retry loop so every\n # attempt re-sends the same key; a caller-provided header always wins.\n key = idempotency_key if idempotency_key is not None else config.get("idempotency_key")\n if (\n key not in (None, False)\n and method.upper() in ("POST", "PATCH")\n and "Idempotency-Key" not in merged_headers\n ):\n merged_headers["Idempotency-Key"] = (\n key if isinstance(key, str) else key() if callable(key) else str(uuid.uuid4())\n )\n\n context = {\n "url": url,\n "method": method.upper(),\n "headers": merged_headers,\n "body": json_body,\n "operation": op,\n }\n middleware: List[Any] = config.get("middleware") or []\n for mw in middleware:\n on_request = getattr(mw, "on_request", None) or (mw.get("on_request") if isinstance(mw, dict) else None)\n if on_request:\n on_request(context)\n\n max_attempts = 1 + int(merged_retry.get("retries", 0))\n retry_on = merged_retry.get("retry_on") or (\n lambda ctx: _default_retry_on(context["method"], context["headers"], ctx.get("response"))\n )\n\n attempt = 0\n while True:\n attempt += 1\n try:\n response = client.request(\n context["method"],\n context["url"],\n headers=context["headers"],\n params=params,\n json=context["body"] if content is None and files is None and data is None else None,\n content=content,\n data=data,\n files=files,\n timeout=effective_timeout if effective_timeout is not None else httpx.USE_CLIENT_DEFAULT,\n )\n except httpx.TimeoutException:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n time.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise ApiTimeoutError(op.get("id", "?"), float(effective_timeout or 0), attempt) from None\n except httpx.TransportError:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n time.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise\n\n for mw in reversed(middleware):\n on_response = getattr(mw, "on_response", None) or (mw.get("on_response") if isinstance(mw, dict) else None)\n if on_response:\n replaced = on_response(response, context)\n if replaced is not None:\n response = replaced\n\n if (\n not response.is_success\n and attempt < max_attempts\n and retry_on({"attempt": attempt, "response": response})\n ):\n time.sleep(_retry_delay(merged_retry, attempt, response.headers.get("retry-after")))\n continue\n return response\n\n\nasync def send_async(\n client: httpx.AsyncClient,\n config: Dict[str, Any],\n op: Dict[str, Any],\n url: str,\n *,\n method: str,\n headers: Optional[Dict[str, str]] = None,\n params: Optional[Dict[str, Any]] = None,\n json_body: Any = None,\n content: Any = None,\n data: Any = None,\n files: Any = None,\n timeout: Optional[float] = None,\n idempotency_key: Any = None,\n retry: Optional[Dict[str, Any]] = None,\n) -> httpx.Response:\n """The async mirror of send() — same retry/timeout/idempotency semantics."""\n merged_retry: Dict[str, Any] = {**(config.get("retry") or {}), **(retry or {})}\n effective_timeout = timeout if timeout is not None else config.get("timeout")\n merged_headers: Dict[str, str] = {**(config.get("headers") or {}), **(headers or {})}\n key = idempotency_key if idempotency_key is not None else config.get("idempotency_key")\n if (\n key not in (None, False)\n and method.upper() in ("POST", "PATCH")\n and "Idempotency-Key" not in merged_headers\n ):\n merged_headers["Idempotency-Key"] = (\n key if isinstance(key, str) else key() if callable(key) else str(uuid.uuid4())\n )\n context = {\n "url": url,\n "method": method.upper(),\n "headers": merged_headers,\n "body": json_body,\n "operation": op,\n }\n middleware: List[Any] = config.get("middleware") or []\n for mw in middleware:\n on_request = getattr(mw, "on_request", None) or (mw.get("on_request") if isinstance(mw, dict) else None)\n if on_request:\n on_request(context)\n max_attempts = 1 + int(merged_retry.get("retries", 0))\n retry_on = merged_retry.get("retry_on") or (\n lambda ctx: _default_retry_on(context["method"], context["headers"], ctx.get("response"))\n )\n attempt = 0\n while True:\n attempt += 1\n try:\n response = await client.request(\n context["method"],\n context["url"],\n headers=context["headers"],\n params=params,\n json=context["body"] if content is None and files is None and data is None else None,\n content=content,\n data=data,\n files=files,\n timeout=effective_timeout if effective_timeout is not None else httpx.USE_CLIENT_DEFAULT,\n )\n except httpx.TimeoutException:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n await asyncio.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise ApiTimeoutError(op.get("id", "?"), float(effective_timeout or 0), attempt) from None\n except httpx.TransportError:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n await asyncio.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise\n for mw in reversed(middleware):\n on_response = getattr(mw, "on_response", None) or (mw.get("on_response") if isinstance(mw, dict) else None)\n if on_response:\n replaced = on_response(response, context)\n if replaced is not None:\n response = replaced\n if (\n not response.is_success\n and attempt < max_attempts\n and retry_on({"attempt": attempt, "response": response})\n ):\n await asyncio.sleep(_retry_delay(merged_retry, attempt, response.headers.get("retry-after")))\n continue\n return response\n', + '_paginate.py': + '# Auto-pagination iterators for generated Python clients — the TypeScript\n# runtime\'s paginate.ts semantics ported: cursor (next-cursor pointer, optional\n# has-more flag, repeated-cursor guard), offset/page (advance by count/one,\n# repeated-page guard, null start treated as absent), and link (RFC 8288\n# `Link: rel="next"` following with relative resolution and a loop guard).\nfrom __future__ import annotations\n\nimport re\nfrom typing import Any, AsyncIterator, Awaitable, Callable, Dict, Iterator, Optional, Tuple\nfrom urllib.parse import parse_qsl, urljoin, urlparse\n\n# call(params) -> (parsed_json, httpx.Response)\nPageCall = Callable[[Dict[str, Any]], Tuple[Any, Any]]\n\n\ndef resolve_pointer(data: Any, pointer: str) -> Any:\n """RFC 6901 JSON pointer over parsed JSON; None on any miss."""\n if pointer == "":\n return data\n if not pointer.startswith("/"):\n return None\n current = data\n for token in pointer[1:].split("/"):\n key = token.replace("~1", "/").replace("~0", "~")\n if isinstance(current, dict):\n current = current.get(key)\n elif isinstance(current, list) and key.isdigit():\n index = int(key)\n current = current[index] if index < len(current) else None\n else:\n return None\n if current is None:\n return None\n return current\n\n\ndef iter_pages(call: PageCall, spec: Dict[str, Any], params: Optional[Dict[str, Any]] = None) -> Iterator[Any]:\n """Yield raw page JSON per the pagination spec; every page is yielded before\n the stop condition is evaluated, so the last page always arrives."""\n style = spec["style"]\n base = dict(params or {})\n if style == "cursor":\n cursor = base.get(spec["param"])\n while True:\n page_params = dict(base)\n if cursor is not None:\n page_params[spec["param"]] = cursor\n page, _response = call(page_params)\n yield page\n if spec.get("has_more") is not None and resolve_pointer(page, spec["has_more"]) is False:\n return\n nxt = resolve_pointer(page, spec.get("next_cursor", ""))\n if nxt is None or nxt == "":\n return\n if not isinstance(nxt, (str, int, float)):\n raise ValueError(f"Pagination cursor at {spec[\'next_cursor\']} is not a string or number")\n if nxt == cursor:\n raise ValueError("Pagination did not advance: the operation returned the same cursor twice")\n cursor = nxt\n elif style == "link":\n yield from _iter_pages_by_link(call, base)\n else: # offset / page\n start = base.get(spec["param"])\n fallback = 1 if style == "page" else 0\n try:\n position = fallback if start in (None, "") else int(start)\n except (TypeError, ValueError):\n position = fallback\n previous_items = None\n while True:\n page, _response = call({**base, spec["param"]: position})\n items = resolve_pointer(page, spec.get("items", ""))\n serialized = repr(items) if isinstance(items, list) else None\n if serialized is not None and serialized == previous_items:\n raise ValueError("Pagination did not advance: the operation returned the same page twice")\n yield page\n if not isinstance(items, list) or len(items) == 0:\n return\n previous_items = serialized\n position += 1 if style == "page" else len(items)\n\n\ndef _link_next(header: Optional[str]) -> Optional[str]:\n if not header:\n return None\n for entry in re.split(r",\\s*(?=<)", header):\n match = re.match(r"^\\s*<([^>]*)>(.*)$", entry)\n if not match:\n continue\n rel = re.search(r\';\\s*rel\\s*=\\s*"?([^";]+)"?\', match.group(2), re.IGNORECASE)\n if rel and "next" in rel.group(1).split():\n return match.group(1)\n return None\n\n\ndef _iter_pages_by_link(call: PageCall, base: Dict[str, Any]) -> Iterator[Any]:\n params = dict(base)\n previous = None\n while True:\n page, response = call(params)\n yield page\n target = _link_next(response.headers.get("link"))\n if target is None:\n return\n page_url = str(response.request.url) if response.request is not None else ""\n nxt = urljoin(page_url or "http://relative.invalid", target)\n if nxt in (previous, page_url):\n raise ValueError(\'Pagination did not advance: the Link rel="next" target repeats\')\n previous = nxt\n link_params: Dict[str, Any] = {}\n for key, value in parse_qsl(urlparse(nxt).query):\n if key in link_params:\n existing = link_params[key]\n link_params[key] = [*existing, value] if isinstance(existing, list) else [existing, value]\n else:\n link_params[key] = value\n params = {**base, **link_params}\n\n\ndef iter_items(call: PageCall, spec: Dict[str, Any], params: Optional[Dict[str, Any]] = None) -> Iterator[Any]:\n """Each page\'s `items` pointer, flattened."""\n for page in iter_pages(call, spec, params):\n items = resolve_pointer(page, spec.get("items", ""))\n if isinstance(items, list):\n yield from items\n\n\n# call(params) -> awaitable of (parsed_json, httpx.Response)\nAsyncPageCall = Callable[[Dict[str, Any]], Awaitable[Tuple[Any, Any]]]\n\n\nasync def aiter_pages(\n call: AsyncPageCall, spec: Dict[str, Any], params: Optional[Dict[str, Any]] = None\n) -> AsyncIterator[Any]:\n """Async mirror of iter_pages — same stop conditions and guards."""\n style = spec["style"]\n base = dict(params or {})\n if style == "cursor":\n cursor = base.get(spec["param"])\n while True:\n page_params = dict(base)\n if cursor is not None:\n page_params[spec["param"]] = cursor\n page, _response = await call(page_params)\n yield page\n if spec.get("has_more") is not None and resolve_pointer(page, spec["has_more"]) is False:\n return\n nxt = resolve_pointer(page, spec.get("next_cursor", ""))\n if nxt is None or nxt == "":\n return\n if not isinstance(nxt, (str, int, float)):\n raise ValueError(f"Pagination cursor at {spec[\'next_cursor\']} is not a string or number")\n if nxt == cursor:\n raise ValueError("Pagination did not advance: the operation returned the same cursor twice")\n cursor = nxt\n elif style == "link":\n previous = None\n link_params: Dict[str, Any] = dict(base)\n while True:\n page, response = await call(link_params)\n yield page\n target = _link_next(response.headers.get("link"))\n if target is None:\n return\n page_url = str(response.request.url) if response.request is not None else ""\n nxt = urljoin(page_url or "http://relative.invalid", target)\n if nxt in (previous, page_url):\n raise ValueError(\'Pagination did not advance: the Link rel="next" target repeats\')\n previous = nxt\n merged: Dict[str, Any] = {}\n for key, value in parse_qsl(urlparse(nxt).query):\n if key in merged:\n existing = merged[key]\n merged[key] = [*existing, value] if isinstance(existing, list) else [existing, value]\n else:\n merged[key] = value\n link_params = {**base, **merged}\n else:\n start = base.get(spec["param"])\n fallback = 1 if style == "page" else 0\n try:\n position = fallback if start in (None, "") else int(start)\n except (TypeError, ValueError):\n position = fallback\n previous_items = None\n while True:\n page, _response = await call({**base, spec["param"]: position})\n items = resolve_pointer(page, spec.get("items", ""))\n serialized = repr(items) if isinstance(items, list) else None\n if serialized is not None and serialized == previous_items:\n raise ValueError("Pagination did not advance: the operation returned the same page twice")\n yield page\n if not isinstance(items, list) or len(items) == 0:\n return\n previous_items = serialized\n position += 1 if style == "page" else len(items)\n\n\nasync def aiter_items(\n call: AsyncPageCall, spec: Dict[str, Any], params: Optional[Dict[str, Any]] = None\n) -> AsyncIterator[Any]:\n async for page in aiter_pages(call, spec, params):\n items = resolve_pointer(page, spec.get("items", ""))\n if isinstance(items, list):\n for item in items:\n yield item\n', + '_sse.py': + '# Server-Sent Events for generated Python clients — the TypeScript runtime\'s\n# sse.ts semantics ported: frame parsing per the EventSource spec (retry must be\n# ASCII digits; comment-only frames skipped; multi-line data joined with \\n) and\n# auto-reconnect resuming from the last event id via Last-Event-ID, with\n# exponential backoff capped at 30s. JSON payloads are parsed when the operation\n# declares a JSON event stream.\nfrom __future__ import annotations\n\nimport asyncio\nimport json\nimport random\nimport time\nfrom dataclasses import dataclass\nfrom typing import Any, AsyncIterator, Callable, Dict, Iterator, Optional\n\nimport httpx\n\n_FRAME_DELIMITER = "\\n\\n"\n\n\n@dataclass\nclass ServerSentEvent:\n data: Any\n event: Optional[str] = None\n id: Optional[str] = None\n retry: Optional[int] = None\n\n\ndef parse_sse_frame(raw: str, data_kind: str = "text") -> Optional[ServerSentEvent]:\n event = None\n data_lines = []\n event_id = None\n retry = None\n saw_field = False\n for line in raw.replace("\\r\\n", "\\n").replace("\\r", "\\n").split("\\n"):\n if line == "" or line.startswith(":"):\n continue\n field, _, value = line.partition(":")\n if value.startswith(" "):\n value = value[1:]\n saw_field = True\n if field == "event":\n event = value\n elif field == "data":\n data_lines.append(value)\n elif field == "id":\n event_id = value\n elif field == "retry" and value.isdigit():\n retry = int(value)\n if not saw_field:\n return None\n text = "\\n".join(data_lines)\n data: Any = text\n if data_kind == "json" and text != "":\n data = json.loads(text)\n return ServerSentEvent(data=data, event=event, id=event_id, retry=retry)\n\n\ndef iter_sse(\n open_stream: Callable[[Dict[str, str]], Any],\n data_kind: str = "text",\n reconnect: bool = True,\n reconnect_delay: float = 1.0,\n) -> Iterator[ServerSentEvent]:\n """Iterate an event stream. `open_stream(extra_headers)` must return an\n httpx streaming-response context manager; it is reopened on dropped\n connections with Last-Event-ID set (fresh call = fresh auth)."""\n last_event_id: Optional[str] = None\n server_retry: Optional[float] = None\n failures = 0\n while True:\n headers = {"Accept": "text/event-stream"}\n if last_event_id is not None:\n headers["Last-Event-ID"] = last_event_id\n try:\n with open_stream(headers) as response:\n if response.status_code >= 400:\n response.read()\n raise httpx.HTTPStatusError(\n f"SSE request failed with status {response.status_code}",\n request=response.request,\n response=response,\n )\n failures = 0\n buffer = ""\n for chunk in response.iter_text():\n buffer += chunk\n while _FRAME_DELIMITER in buffer:\n raw, buffer = buffer.split(_FRAME_DELIMITER, 1)\n parsed = parse_sse_frame(raw, data_kind)\n if parsed is not None:\n if parsed.id is not None:\n last_event_id = parsed.id\n if parsed.retry is not None:\n server_retry = parsed.retry / 1000\n yield parsed\n # Clean end: flush a trailing frame, then finish (no reconnect).\n if buffer.strip():\n parsed = parse_sse_frame(buffer, data_kind)\n if parsed is not None:\n yield parsed\n return\n except httpx.HTTPStatusError:\n raise # a 4xx/5xx is definitive, not a dropped connection\n except (httpx.TransportError, httpx.TimeoutException):\n if not reconnect:\n raise\n failures += 1\n base = server_retry if server_retry is not None else reconnect_delay\n time.sleep(random.uniform(0, min(base * (2 ** (failures - 1)), 30.0)))\n\n\nasync def aiter_sse(\n open_stream: Callable[[Dict[str, str]], Any],\n data_kind: str = "text",\n reconnect: bool = True,\n reconnect_delay: float = 1.0,\n) -> AsyncIterator[ServerSentEvent]:\n """Async mirror of iter_sse; `open_stream` returns an async context manager."""\n last_event_id: Optional[str] = None\n server_retry: Optional[float] = None\n failures = 0\n while True:\n headers = {"Accept": "text/event-stream"}\n if last_event_id is not None:\n headers["Last-Event-ID"] = last_event_id\n try:\n async with open_stream(headers) as response:\n if response.status_code >= 400:\n await response.aread()\n raise httpx.HTTPStatusError(\n f"SSE request failed with status {response.status_code}",\n request=response.request,\n response=response,\n )\n failures = 0\n buffer = ""\n async for chunk in response.aiter_text():\n buffer += chunk\n while _FRAME_DELIMITER in buffer:\n raw, buffer = buffer.split(_FRAME_DELIMITER, 1)\n parsed = parse_sse_frame(raw, data_kind)\n if parsed is not None:\n if parsed.id is not None:\n last_event_id = parsed.id\n if parsed.retry is not None:\n server_retry = parsed.retry / 1000\n yield parsed\n if buffer.strip():\n parsed = parse_sse_frame(buffer, data_kind)\n if parsed is not None:\n yield parsed\n return\n except httpx.HTTPStatusError:\n raise\n except (httpx.TransportError, httpx.TimeoutException):\n if not reconnect:\n raise\n failures += 1\n base = server_retry if server_retry is not None else reconnect_delay\n await asyncio.sleep(random.uniform(0, min(base * (2 ** (failures - 1)), 30.0)))\n', + '_multipart.py': + '# Multipart bodies for generated Python clients — a typed dict/dataclass body is\n# split into httpx\'s (data, files): bytes and file-like values upload as parts,\n# everything else is form data (nested values JSON-encoded, mirroring the\n# TypeScript runtime\'s FormData serialization).\nfrom __future__ import annotations\n\nimport json\nfrom typing import Any, Dict, Tuple\n\nfrom ._decode import encode\n\n\ndef to_multipart(body: Any) -> Tuple[Dict[str, Any], Dict[str, Any]]:\n wire = encode(body)\n data: Dict[str, Any] = {}\n files: Dict[str, Any] = {}\n for key, value in (wire or {}).items():\n if isinstance(value, (bytes, bytearray)) or hasattr(value, "read"):\n files[key] = value\n elif isinstance(value, (dict, list)):\n data[key] = json.dumps(value)\n else:\n data[key] = value\n return data, files\n', } as const; export type PythonRuntimeModuleName = keyof typeof PYTHON_RUNTIME_SOURCES; diff --git a/packages/client-generator/src/generators/__tests__/python.test.ts b/packages/client-generator/src/generators/__tests__/python.test.ts index 2e6325012b..24077b9839 100644 --- a/packages/client-generator/src/generators/__tests__/python.test.ts +++ b/packages/client-generator/src/generators/__tests__/python.test.ts @@ -172,6 +172,12 @@ const CAFE: ApiModel = { headerParams: [], cookieParams: [], security: [['BearerAuth']], + paginationExtension: { + style: 'cursor', + cursorParam: 'after', + nextCursor: '/next', + items: '/items', + }, successResponses: [ { status: '200', @@ -181,6 +187,44 @@ const CAFE: ApiModel = { ], errorResponses: [], }, + { + name: 'streamEvents', + specName: 'streamEvents', + method: 'get', + path: '/events', + tags: ['Orders'], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { + status: '200', + contentType: 'text/event-stream', + schema: { kind: 'object', properties: [] }, + }, + ], + errorResponses: [], + }, + { + name: 'uploadPhoto', + specName: 'uploadPhoto', + method: 'post', + path: '/photos', + tags: ['Orders'], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + requestBody: { + contentType: 'multipart/form-data', + schema: { kind: 'object', properties: [] }, + }, + successResponses: [{ status: '204', contentType: '', schema: { kind: 'unknown' } }], + errorResponses: [], + }, { name: 'getOrder', specName: 'getOrder', @@ -300,3 +344,27 @@ describe('pythonGenerator (full client assembly)', () => { expectCompiles(generate('result')); }); }); + +describe('pythonGenerator parity features', () => { + it('paginated operations gain pages/items iterators, sync and async', () => { + const out = generate(); + expect(out).toContain('"pagination": {"style": "cursor", "param": "after"'); + expect(out).toContain('def list_orders_pages('); + expect(out).toContain('def list_orders_items('); + expect(out).toContain('iter_pages('); + expect(out).toContain('async for page in aiter_pages('); + expect(out).toContain('-> Iterator[OrderPage]:'); + }); + + it('SSE operations stream typed events; multipart bodies route through to_multipart', () => { + const out = generate(); + expect(out).toContain('def stream_events('); + expect(out).toContain('-> Iterator[ServerSentEvent]:'); + expect(out).toContain('iter_sse('); + expect(out).toContain('-> AsyncIterator[ServerSentEvent]:'); + expect(out).toContain('aiter_sse('); + expect(out).toContain('form_data, form_files = to_multipart(body)'); + expect(out).toContain('data=form_data, files=form_files'); + expectCompiles(out); + }); +}); diff --git a/packages/client-generator/src/generators/python.ts b/packages/client-generator/src/generators/python.ts index c307e979f5..8d8cb18f89 100644 --- a/packages/client-generator/src/generators/python.ts +++ b/packages/client-generator/src/generators/python.ts @@ -127,7 +127,9 @@ export function renderPythonModels(model: ApiModel): string { writer.blank(); writer.line('from dataclasses import dataclass'); writer.line('from enum import Enum'); - writer.line('from typing import Any, ClassVar, Dict, List, Literal, Optional, Union'); + writer.line( + 'from typing import Any, AsyncIterator, ClassVar, Dict, Iterator, List, Literal, Optional, Tuple, Union' + ); writer.blank(); writer.blank(); @@ -229,6 +231,53 @@ 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; +} + +/** + * Pagination for one operation — per-op config rule > `x-redocly-pagination` > + * the convention rule (applied only when its advance param exists on the op and + * the op is not excluded). Normalized to the snake_case spec dict the embedded + * Python runtime consumes. Local minimal resolution: the TS resolver's static + * fit verification lives in the TS toolkit; porting it to the neutral layer is + * a recorded follow-up. + */ +function paginationSpec( + op: OperationModel, + emit: { pagination?: Record } +): Record | undefined { + const config = emit.pagination ?? {}; + const id = op.specName ?? op.name; + if (Array.isArray(config.exclude) && config.exclude.includes(id)) return undefined; + const operations = (config.operations ?? {}) as Record>; + let rule: Record | undefined = + operations[id] ?? (op.paginationExtension as Record | undefined); + if (rule === undefined && typeof config.style === 'string') { + const { exclude: _exclude, operations: _operations, ...convention } = config; + const advance = convention.style === 'cursor' ? convention.cursorParam : convention.offsetParam; + const fits = + convention.style === 'link' || + (typeof advance === 'string' && op.queryParams.some((param) => param.name === advance)); + if (fits) rule = convention as Record; + } + if (rule === undefined || typeof rule.style !== 'string') return undefined; + const param = rule.style === 'cursor' ? rule.cursorParam : rule.offsetParam; + return { + style: rule.style, + ...(typeof param === 'string' ? { param } : {}), + ...(typeof rule.nextCursor === 'string' ? { next_cursor: rule.nextCursor } : {}), + ...(typeof rule.hasMore === 'string' ? { has_more: rule.hasMore } : {}), + ...(typeof rule.limitParam === 'string' ? { limit_param: rule.limitParam } : {}), + ...(typeof rule.items === 'string' ? { items: rule.items } : {}), + }; +} + function writeMethod( writer: CodeWriter, op: OperationModel, @@ -258,9 +307,18 @@ function writeMethod( 'idempotency_key: Any = None', ]; const success = successSchema(op); + const sse = sseResponse(op); const returns = - errorMode === 'result' ? 'Result' : success === undefined ? 'None' : pythonType(success); - const prefix = isAsync ? 'async def' : 'def'; + sse !== undefined + ? `${isAsync ? 'AsyncIterator' : 'Iterator'}[ServerSentEvent]` + : errorMode === 'result' + ? 'Result' + : success === undefined + ? 'None' + : pythonType(success); + // 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(', '); @@ -278,7 +336,23 @@ function writeMethod( .map(({ param, python }) => `${JSON.stringify(param.name)}: ${python}`) .join(', '); writer.line(`url = build_url(self._server_url, op["path"], {${pathDict}})`); - const bodyKw = op.requestBody ? ', json_body=encode(body)' : ''; + if (sse !== undefined) { + const dataKind = sse.schema !== undefined && sse.schema.kind !== 'unknown' ? 'json' : 'text'; + writer.block('def _open(extra_headers: Dict[str, str]):', () => { + writer.line( + 'return self._http.stream(op["method"], url, ' + + 'headers={**auth_headers, **(headers or {}), **extra_headers}, params=params, timeout=timeout)' + ); + }); + writer.line(`return ${isAsync ? 'aiter_sse' : 'iter_sse'}(_open, data_kind="${dataKind}")`); + return; + } + if (isMultipart(op)) writer.line('form_data, form_files = to_multipart(body)'); + const bodyKw = op.requestBody + ? isMultipart(op) + ? ', data=form_data, files=form_files' + : ', json_body=encode(body)' + : ''; writer.line( `response = ${awaitKw}${sendFn}(self._http, self._config, op, url, method=op["method"], ` + `headers={**auth_headers, **(headers or {})}, params=params${bodyKw}, ` + @@ -303,11 +377,103 @@ function writeMethod( writer.blank(); } +/** `_pages` / `_items` iterator methods for a paginated operation. */ +function writePaginationWrappers( + writer: CodeWriter, + op: OperationModel, + ident: string, + isAsync: boolean +): void { + const success = successSchema(op); + const pageType = success === undefined ? 'Any' : pythonType(success); + const queryArgs = op.queryParams.map((param) => ({ + param, + python: identifierFor(param.name, { style: 'snake', reserved: PY }), + })); + const kwargs = [ + ...queryArgs.map(({ param, python }) => { + const annotation = pythonType(param.schema); + const optional = annotation.startsWith('Optional[') ? annotation : `Optional[${annotation}]`; + return `${python}: ${optional} = None`; + }), + 'headers: Optional[Dict[str, str]] = None', + 'timeout: Optional[float] = None', + 'retry: Optional[Dict[str, Any]] = None', + ]; + const signature = ['self', '*', ...kwargs].join(', '); + const iterType = isAsync ? 'AsyncIterator' : 'Iterator'; + const pagesFn = isAsync ? 'aiter_pages' : 'iter_pages'; + const itemsFn = isAsync ? 'aiter_items' : 'iter_items'; + + const writeCallClosure = () => { + writer.line('base: Dict[str, Any] = {}'); + for (const { param, python } of queryArgs) { + writer.block(`if ${python} is not None:`, () => { + writer.line(`base[${JSON.stringify(param.name)}] = encode(${python})`); + }); + } + const prefix = isAsync ? 'async def' : 'def'; + const awaitKw = isAsync ? 'await ' : ''; + writer.block(`${prefix} _page(page_params: Dict[str, Any]) -> Tuple[Any, Any]:`, () => { + writer.line('auth_headers, auth_query = resolve_auth(op.get("security") or [], self._auth)'); + writer.line('url = build_url(self._server_url, op["path"], {})'); + writer.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)' + ); + writer.block('if not response.is_success:', () => { + writer.line( + 'raise ApiError(url, response.status_code, response.reason_phrase, _safe_json(response))' + ); + }); + writer.line('return _safe_json(response), response'); + }); + }; + + // pages: raw page JSON decoded into the page model per page. + if (isAsync) { + writer.block(`async def ${ident}_pages(${signature}) -> ${iterType}[${pageType}]:`, () => { + writer.line(`op = _OPERATIONS["${ident}"]`); + writeCallClosure(); + writer.block(`async for page in ${pagesFn}(_page, op["pagination"], base):`, () => { + writer.line(pageType === 'Any' ? 'yield page' : `yield decode(${pageType}, page)`); + }); + }); + writer.blank(); + writer.block(`async def ${ident}_items(${signature}) -> ${iterType}[Any]:`, () => { + writer.line(`op = _OPERATIONS["${ident}"]`); + writeCallClosure(); + writer.block(`async for item in ${itemsFn}(_page, op["pagination"], base):`, () => { + writer.line('yield item'); + }); + }); + } else { + writer.block(`def ${ident}_pages(${signature}) -> ${iterType}[${pageType}]:`, () => { + writer.line(`op = _OPERATIONS["${ident}"]`); + writeCallClosure(); + writer.line( + pageType === 'Any' + ? `return ${pagesFn}(_page, op["pagination"], base)` + : `return (decode(${pageType}, page) for page in ${pagesFn}(_page, op["pagination"], base))` + ); + }); + writer.blank(); + writer.block(`def ${ident}_items(${signature}) -> ${iterType}[Any]:`, () => { + writer.line(`op = _OPERATIONS["${ident}"]`); + writeCallClosure(); + writer.line(`return ${itemsFn}(_page, op["pagination"], base)`); + }); + } + writer.blank(); +} + function writeClientClass( writer: CodeWriter, model: ApiModel, errorMode: 'throw' | 'result', - isAsync: boolean + isAsync: boolean, + paginationSpecs: Map | undefined> ): void { const name = isAsync ? 'AsyncClient' : 'Client'; const httpType = isAsync ? 'httpx.AsyncClient' : 'httpx.Client'; @@ -340,6 +506,9 @@ function writeClientClass( writer.blank(); for (const { op, ident } of operationIdents(model)) { writeMethod(writer, op, ident, errorMode, isAsync); + if (paginationSpecs.get(ident) !== undefined) { + writePaginationWrappers(writer, op, ident, isAsync); + } } }); writer.blank(); @@ -386,6 +555,13 @@ export const pythonGenerator: Generator = ({ model, outputPath, emit }) => { writer.blank(); // The wire-shape descriptor table the runtime routes by. + const paginationSpecs = new Map | undefined>(); + for (const { op, ident } of operationIdents(model)) { + paginationSpecs.set( + ident, + paginationSpec(op, emit as { pagination?: Record }) + ); + } writer.line('_OPERATIONS = {'); writer.indent(() => { for (const { op, ident } of operationIdents(model)) { @@ -394,6 +570,9 @@ export const pythonGenerator: Generator = ({ model, outputPath, emit }) => { method: op.method.toUpperCase(), path: op.path, ...(securitySpecs(op, model).length > 0 ? { security: securitySpecs(op, model) } : {}), + ...(paginationSpecs.get(ident) !== undefined + ? { pagination: paginationSpecs.get(ident) } + : {}), }; writer.line(`"${ident}": ${pythonLiteral(descriptor)},`); } @@ -402,8 +581,8 @@ export const pythonGenerator: Generator = ({ model, outputPath, emit }) => { writer.blank(); writer.blank(); - writeClientClass(writer, model, errorMode, false); - writeClientClass(writer, model, errorMode, true); + writeClientClass(writer, model, errorMode, false, paginationSpecs); + writeClientClass(writer, model, errorMode, true, paginationSpecs); return [{ path: outputPath.replace(/\.[^.\\/]+$/, '.py'), content: writer.toString() }]; }; From 7e871bd3d400c8f2cf6d320fba296eec50b4c7f0 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 11:53:52 +0300 Subject: [PATCH 013/211] feat(client-generator): built-in python generator with Python code samples and a dogfooding guard --- .../src/utils/generate-client-telemetry.ts | 1 + .../__tests__/python-dogfooding.test.ts | 29 +++++++++++++++++++ .../client-generator/src/generators/index.ts | 2 ++ .../client-generator/src/generators/meta.ts | 6 ++++ .../client-generator/src/generators/python.ts | 25 +++++++++++++++- .../client-generator/src/generators/types.ts | 3 +- 6 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 packages/client-generator/src/generators/__tests__/python-dogfooding.test.ts diff --git a/packages/cli/src/utils/generate-client-telemetry.ts b/packages/cli/src/utils/generate-client-telemetry.ts index 0061661edc..b59f2dbe40 100644 --- a/packages/cli/src/utils/generate-client-telemetry.ts +++ b/packages/cli/src/utils/generate-client-telemetry.ts @@ -24,6 +24,7 @@ export const BUILTIN_GENERATOR_NAMES = new Set([ 'swr', 'transformers', 'mock', + 'python', ]); const IMPORT_RE = diff --git a/packages/client-generator/src/generators/__tests__/python-dogfooding.test.ts b/packages/client-generator/src/generators/__tests__/python-dogfooding.test.ts new file mode 100644 index 0000000000..382829b3c2 --- /dev/null +++ b/packages/client-generator/src/generators/__tests__/python-dogfooding.test.ts @@ -0,0 +1,29 @@ +import { readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// The python generator is the flywheel's proof: it must be authored EXACTLY the +// way the AGENTS.md skill teaches users' agents — with the language-neutral +// toolkit only. Any import outside this allowlist (in particular the TS emitter +// toolkit) is a dogfooding violation, and also breaks the promise that a +// python-only selection never loads the `typescript` package. +const ALLOWED_SPECIFIERS = new Set([ + '../authoring/index.js', + '../emitters/python-runtime-sources.js', // pure embedded strings, generated at prepare time + '../intermediate-representation/model.js', // type-only IR shapes + './types.js', // the generator contract +]); + +const source = readFileSync( + resolve(dirname(fileURLToPath(import.meta.url)), '../python.ts'), + 'utf-8' +); + +describe('python generator dogfooding invariant', () => { + it('imports only what the authoring skill offers to any custom generator', () => { + const specifiers = [...source.matchAll(/from '([^']+)'/g)].map((match) => match[1]); + expect(specifiers.length).toBeGreaterThan(0); + const violations = specifiers.filter((specifier) => !ALLOWED_SPECIFIERS.has(specifier)); + expect(violations).toEqual([]); + }); +}); diff --git a/packages/client-generator/src/generators/index.ts b/packages/client-generator/src/generators/index.ts index 26f18249e6..9c12b1edfc 100644 --- a/packages/client-generator/src/generators/index.ts +++ b/packages/client-generator/src/generators/index.ts @@ -1,6 +1,7 @@ import type { EmitOptions } from '../emitters/emit-options.js'; import { BUILTIN_META, validateSelection, type BuiltinMeta } from './meta.js'; import { mockGenerator } from './mock.js'; +import { pythonGenerator, pythonSample } from './python.js'; import { sdkGenerator, sdkSample } from './sdk.js'; import { swrGenerator } from './swr.js'; import { tanstackQueryGenerator } from './tanstack-query.js'; @@ -30,6 +31,7 @@ const RUNS: Record> = 'tanstack-query-solid': { run: tanstackQueryGenerator('solid') }, swr: { run: swrGenerator }, mock: { run: mockGenerator }, + python: { run: pythonGenerator, sample: pythonSample }, }; const GENERATORS = Object.fromEntries( diff --git a/packages/client-generator/src/generators/meta.ts b/packages/client-generator/src/generators/meta.ts index 6e522b02a0..04156a58bb 100644 --- a/packages/client-generator/src/generators/meta.ts +++ b/packages/client-generator/src/generators/meta.ts @@ -51,6 +51,12 @@ export const BUILTIN_META: Record = { requires: ['sdk'], load: () => import('./mock.js').then((m) => ({ run: m.mockGenerator })), }, + // python emits a standalone full Python SDK (httpx) — no TypeScript involved, + // so a python-only selection never loads the `typescript` package. + python: { + load: () => + import('./python.js').then((m) => ({ run: m.pythonGenerator, sample: m.pythonSample })), + }, }; /** diff --git a/packages/client-generator/src/generators/python.ts b/packages/client-generator/src/generators/python.ts index 8d8cb18f89..c3f305a457 100644 --- a/packages/client-generator/src/generators/python.ts +++ b/packages/client-generator/src/generators/python.ts @@ -21,7 +21,7 @@ import type { PropertyModel, SchemaModel, } from '../intermediate-representation/model.js'; -import type { Generator } from './types.js'; +import type { CodeSample, Generator, SampleContext } from './types.js'; const PY = RESERVED_WORDS.python; @@ -586,3 +586,26 @@ export const pythonGenerator: Generator = ({ model, outputPath, emit }) => { return [{ path: outputPath.replace(/\.[^.\\/]+$/, '.py'), content: writer.toString() }]; }; + +/** One idiomatic Python call per operation — feeds `x-codeSamples` for docs. */ +export function pythonSample(op: OperationModel, _ctx: SampleContext): CodeSample { + const ident = identifierFor(op.name, { style: 'snake', reserved: PY }); + const args = [ + ...op.pathParams.map((param) => { + const python = identifierFor(param.name, { style: 'snake', reserved: PY }); + return `${python}="<${python}>"`; + }), + ...op.queryParams + .filter((param) => param.required) + .map((param) => { + const python = identifierFor(param.name, { style: 'snake', reserved: PY }); + return `${python}=...`; + }), + ...(op.requestBody ? ['body=...'] : []), + ]; + return { + lang: 'python', + label: 'Python SDK', + source: `from client import Client\n\nclient = Client()\nresult = client.${ident}(${args.join(', ')})\n`, + }; +} diff --git a/packages/client-generator/src/generators/types.ts b/packages/client-generator/src/generators/types.ts index 1eb05d70c7..85c74d7ad3 100644 --- a/packages/client-generator/src/generators/types.ts +++ b/packages/client-generator/src/generators/types.ts @@ -26,7 +26,8 @@ export type GeneratorName = | 'tanstack-query-solid' | 'swr' | 'transformers' - | 'mock'; + | 'mock' + | 'python'; /** Everything a generator needs to produce its files. */ export type GeneratorInput = { From 00d20d90e9be7f851de5eaa02df9797390f0c1f1 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 12:10:33 +0300 Subject: [PATCH 014/211] =?UTF-8?q?feat(client-generator):=20Python=20e2e?= =?UTF-8?q?=20harness,=20docs,=20and=20changeset=20=E2=80=94=20slice=202?= =?UTF-8?q?=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/python-generator.md | 6 ++ docs/@v2/commands/generate-client.md | 2 +- docs/@v2/configuration/reference/client.md | 2 +- docs/@v2/guides/use-generated-client.md | 15 +++++ packages/cli/src/commands/generate-client.ts | 8 +-- .../client-generator/eject-assets/AGENTS.md | 6 ++ .../generate-client/python-consumer/smoke.py | 39 ++++++++++++ tests/e2e/generate-client/python.test.ts | 62 +++++++++++++++++++ 8 files changed, 133 insertions(+), 7 deletions(-) create mode 100644 .changeset/python-generator.md create mode 100644 tests/e2e/generate-client/python-consumer/smoke.py create mode 100644 tests/e2e/generate-client/python.test.ts diff --git a/.changeset/python-generator.md b/.changeset/python-generator.md new file mode 100644 index 0000000000..772c9986cf --- /dev/null +++ b/.changeset/python-generator.md @@ -0,0 +1,6 @@ +--- +'@redocly/client-generator': minor +'@redocly/cli': minor +--- + +Added a built-in `python` generator — a self-contained full Python SDK over httpx with typed dataclass models, sync and async clients, auth, retries, timeouts, idempotency keys, middleware, pagination iterators, SSE streaming, multipart bodies, and both error modes, plus Python `x-codeSamples`. Generating with only `python` selected does not require the `typescript` package. diff --git a/docs/@v2/commands/generate-client.md b/docs/@v2/commands/generate-client.md index c9d2662fbe..cef481bef9 100644 --- a/docs/@v2/commands/generate-client.md +++ b/docs/@v2/commands/generate-client.md @@ -40,7 +40,7 @@ redocly generate-client [--help] [--version] | `--output-mode` | string | File layout. See [Choose an output mode](#choose-an-output-mode).
**Possible values:** `single`, `split`. Default value is `single`. | | `--runtime` | string | Where the client's engine lives. See [Choose a runtime](#choose-a-runtime).
**Possible values:** `inline`, `package`. Default value is `inline`. | | `--import-ext` | string | Extension in generated relative imports. See [Run with Node directly](../guides/use-generated-client.md#run-with-node-directly).
**Possible values:** `js` (the tsc/bundler convention), `ts` (for Node's built-in type stripping). Default value is `js`. | -| `--generator` | [string] | Generator to run — a built-in name (`tanstack-query` also has `-vue`/`-svelte`/`-solid` variants) or a custom generator's path or package; repeat the flag to run several. Default value is `sdk`. See [Generators](../guides/use-generated-client.md#generators). | +| `--generator` | [string] | Generator to run — a built-in name (`tanstack-query` also has `-vue`/`-svelte`/`-solid` variants; `python` emits a full Python SDK) or a custom generator's path or package; repeat the flag to run several. Default value is `sdk`. See [Generators](../guides/use-generated-client.md#generators). | | `--args-style` | string | How operation inputs are passed. See [Argument style](../guides/use-generated-client.md#argument-style).
**Possible values:** `flat`, `grouped`. Default value is `flat`. | | `--error-mode` | string | How operations report HTTP errors. See [Error handling](../guides/use-generated-client.md#error-handling).
**Possible values:** `throw`, `result`. Default value is `throw`. | | `--date-type` | string | Type of `date`/`date-time` fields; pair `Date` with the `transformers` generator.
**Possible values:** `string`, `Date`. Default value is `string`. | diff --git a/docs/@v2/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md index 3e92fad1c3..ffbe7d4c7e 100644 --- a/docs/@v2/configuration/reference/client.md +++ b/docs/@v2/configuration/reference/client.md @@ -19,7 +19,7 @@ For runs without a configuration file, declare pagination per operation with the | Option | Type | Description | | ---------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `generators` | [string] | Generators to run, in order. Each entry is a built-in name (`sdk`, `zod`, `tanstack-query` — or its `-vue`/`-svelte`/`-solid` variants — `swr`, `mock`, `transformers`) or a custom generator's path or package name. | +| `generators` | [string] | Generators to run, in order. Each entry is a built-in name (`sdk`, `zod`, `tanstack-query` — or its `-vue`/`-svelte`/`-solid` variants — `swr`, `mock`, `transformers`, `python`) or a custom generator's path or package name. | | `outputMode` | string | File layout: `single` or `split`. | | `runtime` | string | Runtime distribution: `inline` or `package`. | | `importExt` | string | Extension in generated relative imports: `js` (default, for tsc and bundlers) or `ts` (for Node's built-in type stripping). | diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index bdb88aad12..d7ca305658 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -26,6 +26,21 @@ redocly generate-client openapi.yaml --output src/client.ts --generator sdk --ge `tanstack-query` and `swr` wrap the throw-mode `sdk` functions, so they require `--error-mode throw`; `transformers` requires `--date-type Date`. See the [`zod`](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/zod), [`tanstack-query`](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/tanstack-query), and [`mock`](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/mock) examples. +### Python SDK + +The `python` generator emits a self-contained `.py` next to the configured output — a full Python SDK over [httpx](https://www.python-httpx.org/) (`pip install httpx`, Python ≥ 3.9): +typed dataclass models (allOf flattened, enums, discriminated unions), a `Client` and an `AsyncClient` with one method per operation, auth, retries with `Retry-After` and jittered backoff, timeouts, idempotency keys, middleware hooks, pagination iterators (`_pages()` / `_items()`, `async for` variants), SSE streaming, and multipart bodies. +`errorMode` maps to raising `ApiError` (default) or returning a `Result` dataclass. +No TypeScript is involved: generating with only `python` selected does not require the `typescript` package. + +```python +from client import Client + +client = Client(auth={"bearer": "TOKEN"}) +for order in client.list_orders_items(limit=50): + print(order) +``` + ## Package runtime By default the runtime is embedded in the generated file, so the client is self-contained. diff --git a/packages/cli/src/commands/generate-client.ts b/packages/cli/src/commands/generate-client.ts index 334ff564bc..86b36e7f7d 100644 --- a/packages/cli/src/commands/generate-client.ts +++ b/packages/cli/src/commands/generate-client.ts @@ -156,7 +156,7 @@ export async function handleGenerateClient({ } try { - logger.info(gray(`\n Generating TypeScript client for ${name}... \n`)); + logger.info(gray(`\n Generating client for ${name}... \n`)); const result = await generateClient({ ...clientConfig, api: path, @@ -165,7 +165,7 @@ export async function handleGenerateClient({ configDir, }); const fileCount = `${result.files.length} ${pluralize('file', result.files.length)}`; - const summary = `TypeScript client successfully generated: ${fileCount} (${ + const summary = `Client successfully generated: ${fileCount} (${ result.bytes } bytes) at ${yellow(result.outputPath)}.`; logger.info('\n' + blue(summary) + '\n'); @@ -173,9 +173,7 @@ export async function handleGenerateClient({ const message = error instanceof Error ? error.message : String(error); generateClientTelemetry.generate_client_error_category = categorizeGenerateClientError(message); - throw new HandledError( - `\n❌ Failed to generate TypeScript client for ${name}.\n ${message}\n` - ); + throw new HandledError(`\n❌ Failed to generate client for ${name}.\n ${message}\n`); } } } diff --git a/packages/client-generator/eject-assets/AGENTS.md b/packages/client-generator/eject-assets/AGENTS.md index 017325b5f4..278eacbc17 100644 --- a/packages/client-generator/eject-assets/AGENTS.md +++ b/packages/client-generator/eject-assets/AGENTS.md @@ -55,6 +55,12 @@ discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, | `docText(description)` | Description as trimmed lines for any comment syntax. | | `AUTHORING_HELPER_NAMES` | The list of the above (introspection). | +Worked example: the built-in `python` generator +(`packages/client-generator/src/generators/python.ts` in the Redocly CLI repo) is +authored with exactly this toolkit and nothing else — models via `flattenAllOf`/ +`enumValues`/`discriminatorCases`, all code through `CodeWriter`, every name through +`identifierFor(..., RESERVED_WORDS.python)`. + TypeScript-emitting generators may additionally use the TS toolkit from `@redocly/client-generator/generate` (`ts`, `printStatements`, `schemaToTypeNode`, …). diff --git a/tests/e2e/generate-client/python-consumer/smoke.py b/tests/e2e/generate-client/python-consumer/smoke.py new file mode 100644 index 0000000000..5719b78586 --- /dev/null +++ b/tests/e2e/generate-client/python-consumer/smoke.py @@ -0,0 +1,39 @@ +# Runtime smoke for the generated Python SDK, exercised against the same Node +# mock server the TypeScript base consumer uses. Run by python.test.ts with: +# python3 smoke.py +import importlib.util +import sys + +client_path, server_url = sys.argv[1], sys.argv[2] +spec = importlib.util.spec_from_file_location("generated_client", client_path) +module = importlib.util.module_from_spec(spec) +# Register BEFORE exec: dataclass ClassVar annotations resolve through +# sys.modules[cls.__module__] at class-creation time. +sys.modules["generated_client"] = module +spec.loader.exec_module(module) + +client = module.Client(server_url=server_url) + +# Typed call with hydration: the response decodes into the generated dataclasses. +pet = client.get_pet_by_id(1) +assert isinstance(pet, module.Pet), f"expected a Pet dataclass, got {type(pet)!r}" +assert isinstance(pet.name, str) and pet.name, "pet.name should hydrate" + +# A collection response hydrates its element type. +pets = client.list_pets() +assert isinstance(pets, list) and all(isinstance(p, module.Pet) for p in pets) + +# A request body encodes through the dataclass (None fields, like the readOnly +# server-managed id, are omitted from the wire payload by encode()). +created = client.create_pet(body=module.Pet(name="Smokey", status="available")) +assert isinstance(created, module.Pet) + +# A non-2xx raises the structured ApiError (a wrong base path 404s every route). +broken = module.Client(server_url=server_url + "/nowhere") +try: + broken.get_pet_by_id(1) + raise AssertionError("expected ApiError for a 404") +except module.ApiError as error: + assert error.status == 404, f"expected 404, got {error.status}" + +print("PYTHON_SMOKE_OK") diff --git a/tests/e2e/generate-client/python.test.ts b/tests/e2e/generate-client/python.test.ts new file mode 100644 index 0000000000..cafb97f395 --- /dev/null +++ b/tests/e2e/generate-client/python.test.ts @@ -0,0 +1,62 @@ +import { spawnSync, type ChildProcess } from 'node:child_process'; +import { existsSync, rmSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { generate, killServer, startServer } from './helpers.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixture = join(__dirname, 'fixtures/base.yaml'); +const consumerDir = join(__dirname, 'python-consumer'); +const generatedFile = join(consumerDir, 'client.py'); + +const SERVER_PORT = 3106; +const SERVER_BASE = `http://127.0.0.1:${SERVER_PORT}`; + +const hasPython = spawnSync('python3', ['--version']).status === 0; +const hasHttpx = hasPython && spawnSync('python3', ['-c', 'import httpx']).status === 0; + +describe('generate-client python generator (end-to-end)', () => { + afterAll(() => { + rmSync(generatedFile, { force: true }); + rmSync(join(consumerDir, '__pycache__'), { recursive: true, force: true }); + }); + + it('generates a self-contained client.py from the CLI', () => { + generate(fixture, join(consumerDir, 'client.ts'), ['--generator', 'python']); + expect(existsSync(generatedFile)).toBe(true); + }); + + it.skipIf(!hasPython)('the generated client is valid Python', () => { + const result = spawnSync('python3', ['-m', 'py_compile', generatedFile], { + encoding: 'utf-8', + }); + expect(result.status, result.stderr).toBe(0); + }); + + it.skipIf(!hasHttpx)( + 'runs real HTTP against the mock server: hydration, bodies, ApiError', + async () => { + let serverProcess: ChildProcess | undefined; + try { + serverProcess = await startServer( + join(__dirname, 'base-consumer/server.ts'), + join(__dirname, 'base-consumer'), + { BASE_SERVER_PORT: String(SERVER_PORT) }, + SERVER_BASE, + 'python-smoke-server' + ); + const result = spawnSync( + 'python3', + [join(consumerDir, 'smoke.py'), generatedFile, SERVER_BASE], + { encoding: 'utf-8' } + ); + expect(result.status, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`).toBe(0); + expect(result.stdout).toContain('PYTHON_SMOKE_OK'); + } finally { + if (serverProcess) await killServer(serverProcess); + } + }, + 60_000 + ); +}); From 4496668df8ca78d89e33e78e5ef2cf333d49fa8a Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 12:22:54 +0300 Subject: [PATCH 015/211] =?UTF-8?q?feat(client-generator):=20neutral=20sch?= =?UTF-8?q?emaAtPointer=20and=20paginationRuleFor=20helpers=20=E2=80=94=20?= =?UTF-8?q?slice-2=20lessons=20landed,=20python=20items=20typed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../@v2/guides/customize-client-generation.md | 2 + .../client-generator/eject-assets/AGENTS.md | 22 +++--- .../authoring/__tests__/pagination.test.ts | 55 ++++++++++++++ .../src/authoring/__tests__/schema.test.ts | 34 +++++++++ .../client-generator/src/authoring/index.ts | 4 + .../src/authoring/pagination.ts | 56 ++++++++++++++ .../client-generator/src/authoring/schema.ts | 46 ++++++++++++ .../src/emitters/pagination.ts | 49 +----------- .../src/generators/__tests__/python.test.ts | 1 + .../client-generator/src/generators/python.ts | 74 ++++++++++--------- 10 files changed, 251 insertions(+), 92 deletions(-) create mode 100644 packages/client-generator/src/authoring/__tests__/pagination.test.ts create mode 100644 packages/client-generator/src/authoring/pagination.ts diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index c228477825..80efb36786 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -83,6 +83,8 @@ The package root exports pure helpers over the API model that cover the cross-la | `casing` / `identifierFor(name, opts)` | camel/pascal/snake/screaming casing; keyword-safe identifiers (`RESERVED_WORDS.python/go/typescript` shipped, pass your own set). | | `CodeWriter` | Indentation-aware text builder — no manual whitespace bookkeeping. | | `docText(description)` | Description text 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 applying to an operation (per-op config > `x-redocly-pagination` > fitting convention), normalized. | A generator that imports only these helpers (and not the TypeScript toolkit below) runs without the `typescript` package installed. diff --git a/packages/client-generator/eject-assets/AGENTS.md b/packages/client-generator/eject-assets/AGENTS.md index 278eacbc17..ab11451193 100644 --- a/packages/client-generator/eject-assets/AGENTS.md +++ b/packages/client-generator/eject-assets/AGENTS.md @@ -44,16 +44,18 @@ 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. | -| `casing` / `identifierFor(name, { style, reserved })` | camel/pascal/snake/screaming; keyword-safe identifiers (`RESERVED_WORDS.python/go/typescript` shipped). | -| `CodeWriter` | Indentation-aware text builder — no manual whitespace bookkeeping. | -| `docText(description)` | Description as trimmed lines for any comment syntax. | -| `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. | +| `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. | +| `casing` / `identifierFor(name, { style, reserved })` | camel/pascal/snake/screaming; keyword-safe identifiers (`RESERVED_WORDS.python/go/typescript` shipped). | +| `CodeWriter` | 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. | +| `AUTHORING_HELPER_NAMES` | The list of the above (introspection). | Worked example: the built-in `python` generator (`packages/client-generator/src/generators/python.ts` in the Redocly CLI repo) is diff --git a/packages/client-generator/src/authoring/__tests__/pagination.test.ts b/packages/client-generator/src/authoring/__tests__/pagination.test.ts new file mode 100644 index 0000000000..79141d4f10 --- /dev/null +++ b/packages/client-generator/src/authoring/__tests__/pagination.test.ts @@ -0,0 +1,55 @@ +import type { OperationModel } from '../../intermediate-representation/model.js'; +import { paginationRuleFor } from '../pagination.js'; + +function op(extra: Partial = {}): OperationModel { + return { + name: 'listOrders', + specName: 'listOrders', + method: 'get', + path: '/orders', + tags: [], + pathParams: [], + queryParams: [ + { name: 'after', in: 'query', required: false, schema: { kind: 'scalar', scalar: 'string' } }, + ], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [], + errorResponses: [], + ...extra, + } as unknown as OperationModel; +} + +const CURSOR = { style: 'cursor', cursorParam: 'after', nextCursor: '/next', items: '/items' }; + +describe('paginationRuleFor', () => { + it('per-operation config beats the x-redocly-pagination extension', () => { + const operation = op({ paginationExtension: { ...CURSOR, items: '/fromExtension' } }); + const rule = paginationRuleFor(operation, { operations: { listOrders: CURSOR } })!; + expect(rule).toEqual({ + style: 'cursor', + param: 'after', + nextCursor: '/next', + items: '/items', + }); + }); + + it('falls back to the extension, then to a fitting convention', () => { + expect(paginationRuleFor(op({ paginationExtension: CURSOR }), undefined)).toMatchObject({ + style: 'cursor', + param: 'after', + }); + // Convention fits: the advance param exists on the operation. + expect(paginationRuleFor(op(), CURSOR)).toMatchObject({ style: 'cursor', param: 'after' }); + // Convention does not fit: no such query param. + expect(paginationRuleFor(op(), { ...CURSOR, cursorParam: 'ghost' })).toBeUndefined(); + }); + + it('honors exclude and returns undefined without any source', () => { + expect( + paginationRuleFor(op({ paginationExtension: CURSOR }), { exclude: ['listOrders'] }) + ).toBeUndefined(); + expect(paginationRuleFor(op(), undefined)).toBeUndefined(); + }); +}); diff --git a/packages/client-generator/src/authoring/__tests__/schema.test.ts b/packages/client-generator/src/authoring/__tests__/schema.test.ts index 71c5fff960..d1b156e7b4 100644 --- a/packages/client-generator/src/authoring/__tests__/schema.test.ts +++ b/packages/client-generator/src/authoring/__tests__/schema.test.ts @@ -1,6 +1,7 @@ import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js'; import { discriminatorCases, + schemaAtPointer, docText, enumValues, flattenAllOf, @@ -109,3 +110,36 @@ describe('docText', () => { expect(docText(undefined)).toEqual([]); }); }); + +describe('schemaAtPointer', () => { + it('walks object properties, arrays, records, and intersections through refs', () => { + const order: SchemaModel = { + kind: 'object', + properties: [{ name: 'id', schema: STRING, required: true }], + }; + const page: SchemaModel = { + kind: 'intersection', + members: [ + { + kind: 'object', + properties: [ + { + name: 'items', + schema: { kind: 'array', items: { kind: 'ref', name: 'Order' } }, + required: true, + }, + ], + }, + ], + }; + const m = model({ Order: order, Page: page }); + expect(schemaAtPointer({ kind: 'ref', name: 'Page' }, '/items/0', m)).toEqual(order); + expect(schemaAtPointer(page, '/items', m)).toEqual({ + kind: 'array', + items: { kind: 'ref', name: 'Order' }, + }); + expect(schemaAtPointer(page, '/missing', m)).toBeUndefined(); + expect(schemaAtPointer(page, 'items', m)).toBeUndefined(); + expect(schemaAtPointer(page, '', m)).toEqual(page); + }); +}); diff --git a/packages/client-generator/src/authoring/index.ts b/packages/client-generator/src/authoring/index.ts index e88b9eec39..cf296ab968 100644 --- a/packages/client-generator/src/authoring/index.ts +++ b/packages/client-generator/src/authoring/index.ts @@ -4,12 +4,14 @@ export { CodeWriter } from './code-writer.js'; export { casing, identifierFor, RESERVED_WORDS } from './naming.js'; +export { paginationRuleFor, type NeutralPaginationRule } from './pagination.js'; export { discriminatorCases, docText, enumValues, flattenAllOf, isNullable, + schemaAtPointer, unwrapNullable, } from './schema.js'; @@ -25,4 +27,6 @@ export const AUTHORING_HELPER_NAMES = [ 'unwrapNullable', 'enumValues', 'docText', + 'schemaAtPointer', + 'paginationRuleFor', ] as const; diff --git a/packages/client-generator/src/authoring/pagination.ts b/packages/client-generator/src/authoring/pagination.ts new file mode 100644 index 0000000000..40c954aac6 --- /dev/null +++ b/packages/client-generator/src/authoring/pagination.ts @@ -0,0 +1,56 @@ +// Language-neutral pagination-rule resolution: which rule applies to an operation +// (per-op config > the `x-redocly-pagination` extension > a fitting convention) and +// its normalized shape. Declaration-based — the TS toolkit's static fit VERIFICATION +// (schema-level advance-param/pointer checks) remains generation-side; this helper is +// what every language generator shares. + +import type { ApiModel, OperationModel } from '../intermediate-representation/model.js'; + +/** The normalized rule a generator renders into its runtime's pagination spec. */ +export type NeutralPaginationRule = { + style: string; + /** The advance query parameter (cursor/offset/page styles). */ + param?: string; + nextCursor?: string; + hasMore?: string; + limitParam?: string; + items?: string; +}; + +/** + * Pagination for one operation. The convention rule applies only when its advance + * parameter exists on the operation (`link` needs none); `exclude` kills every source. + * Returns undefined when the operation does not paginate. + */ +export function paginationRuleFor( + op: OperationModel, + config: Record | undefined, + _model?: ApiModel +): NeutralPaginationRule | undefined { + const configuration = config ?? {}; + const id = op.specName ?? op.name; + if (Array.isArray(configuration.exclude) && configuration.exclude.includes(id)) { + return undefined; + } + const operations = (configuration.operations ?? {}) as Record>; + let rule: Record | undefined = + operations[id] ?? (op.paginationExtension as Record | undefined); + if (rule === undefined && typeof configuration.style === 'string') { + const { exclude: _exclude, operations: _operations, ...convention } = configuration; + const advance = convention.style === 'cursor' ? convention.cursorParam : convention.offsetParam; + const fits = + convention.style === 'link' || + (typeof advance === 'string' && op.queryParams.some((param) => param.name === advance)); + if (fits) rule = convention as Record; + } + if (rule === undefined || typeof rule.style !== 'string') return undefined; + const param = rule.style === 'cursor' ? rule.cursorParam : rule.offsetParam; + return { + style: rule.style, + ...(typeof param === 'string' ? { param } : {}), + ...(typeof rule.nextCursor === 'string' ? { nextCursor: rule.nextCursor } : {}), + ...(typeof rule.hasMore === 'string' ? { hasMore: rule.hasMore } : {}), + ...(typeof rule.limitParam === 'string' ? { limitParam: rule.limitParam } : {}), + ...(typeof rule.items === 'string' ? { items: rule.items } : {}), + }; +} diff --git a/packages/client-generator/src/authoring/schema.ts b/packages/client-generator/src/authoring/schema.ts index 26eacf1de5..4de86c9970 100644 --- a/packages/client-generator/src/authoring/schema.ts +++ b/packages/client-generator/src/authoring/schema.ts @@ -93,3 +93,49 @@ export function docText(description?: string): string[] { while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop(); return lines; } + +/** One pointer step over a (dereferenced) schema; an intersection takes the LAST member that resolves, since later `allOf` members refine earlier ones. */ +function stepIntoSchema( + schema: SchemaModel, + key: string, + model: ApiModel +): SchemaModel | undefined { + if (schema.kind === 'object') return schema.properties.find((p) => p.name === key)?.schema; + if (schema.kind === 'record') return schema.value; + if (schema.kind === 'array' && /^(0|[1-9]\d*)$/.test(key)) return schema.items; + if (schema.kind === 'intersection') { + let match: SchemaModel | undefined; + for (const member of schema.members) { + const target = deref(member, model); + if (target === undefined) continue; + match = stepIntoSchema(target, key, model) ?? match; + } + return match; + } + return undefined; +} + +/** + * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) over a schema, walking the + * VALUE shape it describes: object property steps by name, record values for any token, + * array items for numeric tokens, with `ref` steps resolved through the model's named + * schemas (cycle-guarded) and intersections (`allOf`) resolved across their members. + * Unions bail (genuinely ambiguous). Returns `undefined` on any miss. + */ +export function schemaAtPointer( + schema: SchemaModel, + pointer: string, + model: ApiModel +): SchemaModel | undefined { + let current = deref(schema, model); + if (current === undefined || (pointer !== '' && !pointer.startsWith('/'))) return undefined; + if (pointer === '') return current; + for (const token of pointer.slice(1).split('/')) { + const key = token.replaceAll('~1', '/').replaceAll('~0', '~'); + const next = stepIntoSchema(current, key, model); + if (next === undefined) return undefined; + current = deref(next, model); + if (current === undefined) return undefined; + } + return current; +} diff --git a/packages/client-generator/src/emitters/pagination.ts b/packages/client-generator/src/emitters/pagination.ts index 41ecc8553c..8dea94e9a1 100644 --- a/packages/client-generator/src/emitters/pagination.ts +++ b/packages/client-generator/src/emitters/pagination.ts @@ -8,6 +8,7 @@ import { isPlainObject, logger } from '@redocly/openapi-core'; +import { schemaAtPointer as resolveSchemaPointer } from '../authoring/schema.js'; import { allOperations, type ApiModel, @@ -271,52 +272,8 @@ function ruleShapeProblem(rule: unknown): string | undefined { return undefined; } -/** - * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) over a schema, walking the - * VALUE shape it describes: object property steps by name, record values for any token, - * array items for numeric tokens, with `ref` steps resolved through the model's named - * schemas (cycle-guarded). Intersections (`allOf` — the common collection-base pattern) - * resolve across their members; unions bail (genuinely ambiguous — v1 is strict). - * Returns `undefined` on any miss — the caller decides whether that is an error. - */ -export function resolveSchemaPointer( - schema: SchemaModel, - pointer: string, - model: ApiModel -): SchemaModel | undefined { - let current = deref(schema, model); - if (current === undefined || (pointer !== '' && !pointer.startsWith('/'))) return undefined; - if (pointer === '') return current; - for (const token of pointer.slice(1).split('/')) { - const key = token.replaceAll('~1', '/').replaceAll('~0', '~'); - const next = stepIntoSchema(current, key, model); - if (next === undefined) return undefined; - current = deref(next, model); - if (current === undefined) return undefined; - } - return current; -} - -/** One pointer step over a (dereferenced) schema; an intersection takes the LAST member that resolves, since later `allOf` members refine earlier ones. */ -function stepIntoSchema( - schema: SchemaModel, - key: string, - model: ApiModel -): SchemaModel | undefined { - if (schema.kind === 'object') return schema.properties.find((p) => p.name === key)?.schema; - if (schema.kind === 'record') return schema.value; - if (schema.kind === 'array' && /^(0|[1-9]\d*)$/.test(key)) return schema.items; - if (schema.kind === 'intersection') { - let match: SchemaModel | undefined; - for (const member of schema.members) { - const target = deref(member, model); - if (target === undefined) continue; - match = stepIntoSchema(target, key, model) ?? match; - } - return match; - } - return undefined; -} +/** The neutral RFC 6901 schema walker, re-exported under its original name here. */ +export { schemaAtPointer as resolveSchemaPointer } from '../authoring/schema.js'; /** A (dereferenced) schema named for a fit-error message; scalars/enums by their scalar. */ function describeSchema(schema: SchemaModel | undefined): string { diff --git a/packages/client-generator/src/generators/__tests__/python.test.ts b/packages/client-generator/src/generators/__tests__/python.test.ts index 24077b9839..1965bf09cd 100644 --- a/packages/client-generator/src/generators/__tests__/python.test.ts +++ b/packages/client-generator/src/generators/__tests__/python.test.ts @@ -351,6 +351,7 @@ describe('pythonGenerator parity features', () => { expect(out).toContain('"pagination": {"style": "cursor", "param": "after"'); expect(out).toContain('def list_orders_pages('); expect(out).toContain('def list_orders_items('); + expect(out).toContain('-> Iterator[Order]:'); // typed via schemaAtPointer on the items pointer expect(out).toContain('iter_pages('); expect(out).toContain('async for page in aiter_pages('); expect(out).toContain('-> Iterator[OrderPage]:'); diff --git a/packages/client-generator/src/generators/python.ts b/packages/client-generator/src/generators/python.ts index c3f305a457..31cbd73cf1 100644 --- a/packages/client-generator/src/generators/python.ts +++ b/packages/client-generator/src/generators/python.ts @@ -5,6 +5,8 @@ import { CodeWriter, + paginationRuleFor, + schemaAtPointer, discriminatorCases, docText, enumValues, @@ -240,41 +242,21 @@ function isMultipart(op: OperationModel): boolean { return op.requestBody?.contentType.toLowerCase().includes('multipart') ?? false; } -/** - * Pagination for one operation — per-op config rule > `x-redocly-pagination` > - * the convention rule (applied only when its advance param exists on the op and - * the op is not excluded). Normalized to the snake_case spec dict the embedded - * Python runtime consumes. Local minimal resolution: the TS resolver's static - * fit verification lives in the TS toolkit; porting it to the neutral layer is - * a recorded follow-up. - */ +/** The neutral pagination rule mapped to the snake_case spec dict the embedded + * Python runtime consumes. */ function paginationSpec( op: OperationModel, emit: { pagination?: Record } ): Record | undefined { - const config = emit.pagination ?? {}; - const id = op.specName ?? op.name; - if (Array.isArray(config.exclude) && config.exclude.includes(id)) return undefined; - const operations = (config.operations ?? {}) as Record>; - let rule: Record | undefined = - operations[id] ?? (op.paginationExtension as Record | undefined); - if (rule === undefined && typeof config.style === 'string') { - const { exclude: _exclude, operations: _operations, ...convention } = config; - const advance = convention.style === 'cursor' ? convention.cursorParam : convention.offsetParam; - const fits = - convention.style === 'link' || - (typeof advance === 'string' && op.queryParams.some((param) => param.name === advance)); - if (fits) rule = convention as Record; - } - if (rule === undefined || typeof rule.style !== 'string') return undefined; - const param = rule.style === 'cursor' ? rule.cursorParam : rule.offsetParam; + const rule = paginationRuleFor(op, emit.pagination); + if (rule === undefined) return undefined; return { style: rule.style, - ...(typeof param === 'string' ? { param } : {}), - ...(typeof rule.nextCursor === 'string' ? { next_cursor: rule.nextCursor } : {}), - ...(typeof rule.hasMore === 'string' ? { has_more: rule.hasMore } : {}), - ...(typeof rule.limitParam === 'string' ? { limit_param: rule.limitParam } : {}), - ...(typeof rule.items === 'string' ? { items: rule.items } : {}), + ...(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 } : {}), }; } @@ -382,7 +364,8 @@ function writePaginationWrappers( writer: CodeWriter, op: OperationModel, ident: string, - isAsync: boolean + isAsync: boolean, + itemType: string ): void { const success = successSchema(op); const pageType = success === undefined ? 'Any' : pythonType(success); @@ -441,11 +424,11 @@ function writePaginationWrappers( }); }); writer.blank(); - writer.block(`async def ${ident}_items(${signature}) -> ${iterType}[Any]:`, () => { + writer.block(`async def ${ident}_items(${signature}) -> ${iterType}[${itemType}]:`, () => { writer.line(`op = _OPERATIONS["${ident}"]`); writeCallClosure(); writer.block(`async for item in ${itemsFn}(_page, op["pagination"], base):`, () => { - writer.line('yield item'); + writer.line(itemType === 'Any' ? 'yield item' : `yield decode(${itemType}, item)`); }); }); } else { @@ -459,10 +442,14 @@ function writePaginationWrappers( ); }); writer.blank(); - writer.block(`def ${ident}_items(${signature}) -> ${iterType}[Any]:`, () => { + writer.block(`def ${ident}_items(${signature}) -> ${iterType}[${itemType}]:`, () => { writer.line(`op = _OPERATIONS["${ident}"]`); writeCallClosure(); - writer.line(`return ${itemsFn}(_page, op["pagination"], base)`); + writer.line( + itemType === 'Any' + ? `return ${itemsFn}(_page, op["pagination"], base)` + : `return (decode(${itemType}, item) for item in ${itemsFn}(_page, op["pagination"], base))` + ); }); } writer.blank(); @@ -506,8 +493,23 @@ function writeClientClass( writer.blank(); for (const { op, ident } of operationIdents(model)) { writeMethod(writer, op, ident, errorMode, isAsync); - if (paginationSpecs.get(ident) !== undefined) { - writePaginationWrappers(writer, op, ident, isAsync); + const spec = paginationSpecs.get(ident); + if (spec !== undefined) { + const success = successSchema(op); + // Resolve the items ARRAY, then take its raw element schema — a `ref` + // element keeps its name (a deref'd result would type as Any). + const itemsArray = + success !== undefined && typeof spec.items === 'string' + ? schemaAtPointer(success, spec.items, model) + : undefined; + const element = itemsArray?.kind === 'array' ? itemsArray.items : undefined; + writePaginationWrappers( + writer, + op, + ident, + isAsync, + element === undefined ? 'Any' : pythonType(element) + ); } } }); From b9045b029565e444ee96c97d3880de30c012c80b Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 12:25:03 +0300 Subject: [PATCH 016/211] =?UTF-8?q?feat(client-generator):=20Go=20runtime?= =?UTF-8?q?=20core=20(errors,=20auth,=20send=20loop)=20=E2=80=94=20stdlib-?= =?UTF-8?q?only,=20vetted,=20embedded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/client-generator/go-runtime/go.mod | 3 + .../client-generator/go-runtime/runtime.go | 388 ++++++++++++++++++ .../scripts/generate-runtime-sources.mjs | 13 + .../src/emitters/go-runtime-sources.ts | 3 + .../__tests__/go-runtime-embed.test.ts | 32 ++ 5 files changed, 439 insertions(+) create mode 100644 packages/client-generator/go-runtime/go.mod create mode 100644 packages/client-generator/go-runtime/runtime.go create mode 100644 packages/client-generator/src/emitters/go-runtime-sources.ts create mode 100644 packages/client-generator/src/generators/__tests__/go-runtime-embed.test.ts diff --git a/packages/client-generator/go-runtime/go.mod b/packages/client-generator/go-runtime/go.mod new file mode 100644 index 0000000000..96f6641764 --- /dev/null +++ b/packages/client-generator/go-runtime/go.mod @@ -0,0 +1,3 @@ +module redocly.com/client-generator/go-runtime + +go 1.21 diff --git a/packages/client-generator/go-runtime/runtime.go b/packages/client-generator/go-runtime/runtime.go new file mode 100644 index 0000000000..478ed705d7 --- /dev/null +++ b/packages/client-generator/go-runtime/runtime.go @@ -0,0 +1,388 @@ +// Package client — the embedded runtime for generated Go SDKs. Hand-authored +// once and stitched into every generated client (see +// scripts/generate-runtime-sources.mjs), semantically in lockstep with the +// TypeScript runtime: auth OR-alternatives, a retry loop with Retry-After and +// full-jitter backoff, per-attempt timeouts, idempotency keys, and middleware +// hooks. Standard library only — a generated Go SDK has zero dependencies. +package client + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "math/rand" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// APIError is returned for a non-2xx response, carrying the decoded error body. +type APIError struct { + URL string + Status int + StatusText string + Body any +} + +func (e *APIError) Error() string { + return fmt.Sprintf("request failed with status %d", e.Status) +} + +// TimeoutError is returned when a request attempt exceeds the configured +// timeout — carrying the context a log line needs. +type TimeoutError struct { + OperationID string + Timeout time.Duration + Attempt int +} + +func (e *TimeoutError) Error() string { + return fmt.Sprintf("request %q timed out after %s (attempt %d)", e.OperationID, e.Timeout, e.Attempt) +} + +// SecuritySpec mirrors the descriptor table's security entries. +type SecuritySpec struct { + Scheme string + Kind string // "bearer" | "basic" | "apiKey" + Name string // header/query/cookie name for apiKey + In string // "header" | "query" | "cookie" +} + +// Auth holds the client credentials; zero value = anonymous. +type Auth struct { + Bearer func() string + Basic *BasicAuth + APIKey map[string]func() string +} + +type BasicAuth struct { + Username string + Password string +} + +// RetryConfig mirrors the TypeScript runtime's retry policy knobs. +type RetryConfig struct { + Retries int + RetryDelay time.Duration // base; default 1s + RetryStrategy string // "" (exponential) | "fixed" + NoJitter bool + // RetryOn fully replaces the default predicate when set. + RetryOn func(attempt int, resp *http.Response, err error) bool +} + +// Middleware hooks run around every request (OnRequest before serialization order +// is N/A in Go — bodies are values; OnResponse runs in reverse registration order). +type Middleware struct { + OnRequest func(req *http.Request) + OnResponse func(resp *http.Response) +} + +// Config is the per-client configuration shared by every operation method. +type Config struct { + ServerURL string + HTTPClient *http.Client + Headers map[string]string + Timeout time.Duration + Retry RetryConfig + Middleware []Middleware + IdempotencyKey func() string + Auth Auth +} + +func resolveToken(provider func() string) string { + if provider == nil { + return "" + } + return provider() +} + +func schemeConfigured(spec SecuritySpec, auth Auth) bool { + switch spec.Kind { + case "apiKey": + _, ok := auth.APIKey[spec.Scheme] + return ok + case "bearer": + return auth.Bearer != nil + default: + return auth.Basic != nil + } +} + +// resolveAuth applies the first fully-configured OR-alternative; when none is, +// the first alternative's configured schemes are still sent (the server rejects +// the request — same behavior as the TypeScript runtime). +func resolveAuth(security [][]SecuritySpec, auth Auth) (map[string]string, url.Values) { + headers := map[string]string{} + query := url.Values{} + if len(security) == 0 { + return headers, query + } + alternative := security[0] + for _, candidate := range security { + all := true + for _, spec := range candidate { + if !schemeConfigured(spec, auth) { + all = false + break + } + } + if all { + alternative = candidate + break + } + } + var cookies []string + for _, spec := range alternative { + switch spec.Kind { + case "apiKey": + provider, ok := auth.APIKey[spec.Scheme] + if !ok { + continue + } + value := resolveToken(provider) + switch spec.In { + case "query": + query.Set(spec.Name, value) + case "cookie": + cookies = append(cookies, spec.Name+"="+url.QueryEscape(value)) + default: + headers[spec.Name] = value + } + case "bearer": + if auth.Bearer != nil { + headers["Authorization"] = "Bearer " + resolveToken(auth.Bearer) + } + default: + if auth.Basic != nil { + token := base64.StdEncoding.EncodeToString([]byte(auth.Basic.Username + ":" + auth.Basic.Password)) + headers["Authorization"] = "Basic " + token + } + } + } + if len(cookies) > 0 { + headers["Cookie"] = strings.Join(cookies, "; ") + } + return headers, query +} + +// buildURL substitutes {param} path placeholders with percent-encoded values. +func buildURL(serverURL, path string, pathParams map[string]string) string { + filled := path + for name, value := range pathParams { + filled = strings.ReplaceAll(filled, "{"+name+"}", url.PathEscape(value)) + } + return strings.TrimRight(serverURL, "/") + filled +} + +var transientStatus = map[int]bool{408: true, 429: true, 500: true, 502: true, 503: true, 504: true} + +func defaultRetryOn(method string, headers map[string]string, resp *http.Response, err error) bool { + safe := false + switch strings.ToUpper(method) { + case "GET", "HEAD", "PUT", "DELETE", "OPTIONS": + safe = true + } + if _, ok := headers["Idempotency-Key"]; ok { + safe = true + } + if !safe { + return false + } + if err != nil { + return true + } + return resp != nil && transientStatus[resp.StatusCode] +} + +func retryDelay(retry RetryConfig, attempt int, retryAfter string) time.Duration { + if retryAfter != "" { + if seconds, err := strconv.ParseFloat(retryAfter, 64); err == nil { + return time.Duration(seconds * float64(time.Second)) + } + } + base := retry.RetryDelay + if base == 0 { + base = time.Second + } + raw := base + if retry.RetryStrategy != "fixed" { + raw = base * time.Duration(1<<(attempt-1)) + } + if retry.NoJitter { + return raw + } + return time.Duration(rand.Int63n(int64(raw) + 1)) +} + +type requestSpec struct { + OperationID string + Method string + URL string + Headers map[string]string + Query url.Values + Body io.Reader + ContentType string + Timeout time.Duration + Retry *RetryConfig + IdempotencyKey string + // bodyBytes is retained so retries can replay the body. + bodyBytes []byte +} + +// send is the request core: header merge, idempotency keys, the retry loop +// (fresh timeout budget per attempt), and the middleware onion. +func send(ctx context.Context, config *Config, spec requestSpec) (*http.Response, error) { + retry := config.Retry + if spec.Retry != nil { + retry = *spec.Retry + } + timeout := config.Timeout + if spec.Timeout != 0 { + timeout = spec.Timeout + } + headers := map[string]string{} + for key, value := range config.Headers { + headers[key] = value + } + for key, value := range spec.Headers { + headers[key] = value + } + method := strings.ToUpper(spec.Method) + if (method == "POST" || method == "PATCH") && headers["Idempotency-Key"] == "" { + if spec.IdempotencyKey != "" { + headers["Idempotency-Key"] = spec.IdempotencyKey + } else if config.IdempotencyKey != nil { + headers["Idempotency-Key"] = config.IdempotencyKey() + } + } + httpClient := config.HTTPClient + if httpClient == nil { + httpClient = http.DefaultClient + } + if spec.Body != nil { + payload, err := io.ReadAll(spec.Body) + if err != nil { + return nil, err + } + spec.bodyBytes = payload + } + fullURL := spec.URL + if len(spec.Query) > 0 { + separator := "?" + if strings.Contains(fullURL, "?") { + separator = "&" + } + fullURL += separator + spec.Query.Encode() + } + maxAttempts := 1 + retry.Retries + for attempt := 1; ; attempt++ { + attemptCtx := ctx + var cancel context.CancelFunc + if timeout > 0 { + attemptCtx, cancel = context.WithTimeout(ctx, timeout) + } + var bodyReader io.Reader + if spec.bodyBytes != nil { + bodyReader = bytes.NewReader(spec.bodyBytes) + } + req, err := http.NewRequestWithContext(attemptCtx, method, fullURL, bodyReader) + if err != nil { + if cancel != nil { + cancel() + } + return nil, err + } + for key, value := range headers { + req.Header.Set(key, value) + } + if spec.ContentType != "" && spec.bodyBytes != nil { + req.Header.Set("Content-Type", spec.ContentType) + } + for _, mw := range config.Middleware { + if mw.OnRequest != nil { + mw.OnRequest(req) + } + } + resp, err := httpClient.Do(req) + shouldRetry := retry.RetryOn + retryable := false + if shouldRetry != nil { + retryable = shouldRetry(attempt, resp, err) + } else { + retryable = defaultRetryOn(method, headers, resp, err) + } + if err != nil { + if cancel != nil { + cancel() + } + timedOut := errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil + if attempt < maxAttempts && retryable { + time.Sleep(retryDelay(retry, attempt, "")) + continue + } + if timedOut { + return nil, &TimeoutError{OperationID: spec.OperationID, Timeout: timeout, Attempt: attempt} + } + return nil, err + } + for i := len(config.Middleware) - 1; i >= 0; i-- { + if config.Middleware[i].OnResponse != nil { + config.Middleware[i].OnResponse(resp) + } + } + if resp.StatusCode >= 400 && attempt < maxAttempts && retryable { + after := resp.Header.Get("Retry-After") + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + if cancel != nil { + cancel() + } + time.Sleep(retryDelay(retry, attempt, after)) + continue + } + // The response body outlives this call; tie the attempt context's lifetime to it. + if cancel != nil { + resp.Body = &cancelOnClose{ReadCloser: resp.Body, cancel: cancel} + } + return resp, nil + } +} + +type cancelOnClose struct { + io.ReadCloser + cancel context.CancelFunc +} + +func (c *cancelOnClose) Close() error { + c.cancel() + return c.ReadCloser.Close() +} + +// decodeJSON decodes a response body into target; a nil target drains and closes. +func decodeJSON(resp *http.Response, target any) error { + defer resp.Body.Close() + if target == nil { + _, err := io.Copy(io.Discard, resp.Body) + return err + } + return json.NewDecoder(resp.Body).Decode(target) +} + +// apiErrorFrom builds the structured error for a non-2xx response. +func apiErrorFrom(resp *http.Response, requestURL string) error { + defer resp.Body.Close() + var body any + data, _ := io.ReadAll(resp.Body) + if len(data) > 0 { + if err := json.Unmarshal(data, &body); err != nil { + body = string(data) + } + } + return &APIError{URL: requestURL, Status: resp.StatusCode, StatusText: resp.Status, Body: body} +} diff --git a/packages/client-generator/scripts/generate-runtime-sources.mjs b/packages/client-generator/scripts/generate-runtime-sources.mjs index 11ef912c12..f8ab057874 100644 --- a/packages/client-generator/scripts/generate-runtime-sources.mjs +++ b/packages/client-generator/scripts/generate-runtime-sources.mjs @@ -97,6 +97,19 @@ writeFileSync( ].join('\n') ); +// The Go runtime embeds the same way (a single stdlib-only module). +const goDir = join(pkgRoot, 'go-runtime'); +const goOut = join(pkgRoot, 'src', 'emitters', 'go-runtime-sources.ts'); +const goSource = readFileSync(join(goDir, 'runtime.go'), 'utf-8'); +writeFileSync( + goOut, + [ + '// GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`.', + `export const GO_RUNTIME_SOURCE = ${toStringLiteral(goSource)};`, + '', + ].join('\n') +); + const content = [ '// GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`.', 'export const RUNTIME_SOURCES = {', diff --git a/packages/client-generator/src/emitters/go-runtime-sources.ts b/packages/client-generator/src/emitters/go-runtime-sources.ts new file mode 100644 index 0000000000..5c23d31527 --- /dev/null +++ b/packages/client-generator/src/emitters/go-runtime-sources.ts @@ -0,0 +1,3 @@ +// GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`. +export const GO_RUNTIME_SOURCE = + '// Package client — the embedded runtime for generated Go SDKs. Hand-authored\n// once and stitched into every generated client (see\n// scripts/generate-runtime-sources.mjs), semantically in lockstep with the\n// TypeScript runtime: auth OR-alternatives, a retry loop with Retry-After and\n// full-jitter backoff, per-attempt timeouts, idempotency keys, and middleware\n// hooks. Standard library only — a generated Go SDK has zero dependencies.\npackage client\n\nimport (\n\t"bytes"\n\t"context"\n\t"encoding/base64"\n\t"encoding/json"\n\t"errors"\n\t"fmt"\n\t"io"\n\t"math/rand"\n\t"net/http"\n\t"net/url"\n\t"strconv"\n\t"strings"\n\t"time"\n)\n\n// APIError is returned for a non-2xx response, carrying the decoded error body.\ntype APIError struct {\n\tURL string\n\tStatus int\n\tStatusText string\n\tBody any\n}\n\nfunc (e *APIError) Error() string {\n\treturn fmt.Sprintf("request failed with status %d", e.Status)\n}\n\n// TimeoutError is returned when a request attempt exceeds the configured\n// timeout — carrying the context a log line needs.\ntype TimeoutError struct {\n\tOperationID string\n\tTimeout time.Duration\n\tAttempt int\n}\n\nfunc (e *TimeoutError) Error() string {\n\treturn fmt.Sprintf("request %q timed out after %s (attempt %d)", e.OperationID, e.Timeout, e.Attempt)\n}\n\n// SecuritySpec mirrors the descriptor table\'s security entries.\ntype SecuritySpec struct {\n\tScheme string\n\tKind string // "bearer" | "basic" | "apiKey"\n\tName string // header/query/cookie name for apiKey\n\tIn string // "header" | "query" | "cookie"\n}\n\n// Auth holds the client credentials; zero value = anonymous.\ntype Auth struct {\n\tBearer func() string\n\tBasic *BasicAuth\n\tAPIKey map[string]func() string\n}\n\ntype BasicAuth struct {\n\tUsername string\n\tPassword string\n}\n\n// RetryConfig mirrors the TypeScript runtime\'s retry policy knobs.\ntype RetryConfig struct {\n\tRetries int\n\tRetryDelay time.Duration // base; default 1s\n\tRetryStrategy string // "" (exponential) | "fixed"\n\tNoJitter bool\n\t// RetryOn fully replaces the default predicate when set.\n\tRetryOn func(attempt int, resp *http.Response, err error) bool\n}\n\n// Middleware hooks run around every request (OnRequest before serialization order\n// is N/A in Go — bodies are values; OnResponse runs in reverse registration order).\ntype Middleware struct {\n\tOnRequest func(req *http.Request)\n\tOnResponse func(resp *http.Response)\n}\n\n// Config is the per-client configuration shared by every operation method.\ntype Config struct {\n\tServerURL string\n\tHTTPClient *http.Client\n\tHeaders map[string]string\n\tTimeout time.Duration\n\tRetry RetryConfig\n\tMiddleware []Middleware\n\tIdempotencyKey func() string\n\tAuth Auth\n}\n\nfunc resolveToken(provider func() string) string {\n\tif provider == nil {\n\t\treturn ""\n\t}\n\treturn provider()\n}\n\nfunc schemeConfigured(spec SecuritySpec, auth Auth) bool {\n\tswitch spec.Kind {\n\tcase "apiKey":\n\t\t_, ok := auth.APIKey[spec.Scheme]\n\t\treturn ok\n\tcase "bearer":\n\t\treturn auth.Bearer != nil\n\tdefault:\n\t\treturn auth.Basic != nil\n\t}\n}\n\n// resolveAuth applies the first fully-configured OR-alternative; when none is,\n// the first alternative\'s configured schemes are still sent (the server rejects\n// the request — same behavior as the TypeScript runtime).\nfunc resolveAuth(security [][]SecuritySpec, auth Auth) (map[string]string, url.Values) {\n\theaders := map[string]string{}\n\tquery := url.Values{}\n\tif len(security) == 0 {\n\t\treturn headers, query\n\t}\n\talternative := security[0]\n\tfor _, candidate := range security {\n\t\tall := true\n\t\tfor _, spec := range candidate {\n\t\t\tif !schemeConfigured(spec, auth) {\n\t\t\t\tall = false\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif all {\n\t\t\talternative = candidate\n\t\t\tbreak\n\t\t}\n\t}\n\tvar cookies []string\n\tfor _, spec := range alternative {\n\t\tswitch spec.Kind {\n\t\tcase "apiKey":\n\t\t\tprovider, ok := auth.APIKey[spec.Scheme]\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalue := resolveToken(provider)\n\t\t\tswitch spec.In {\n\t\t\tcase "query":\n\t\t\t\tquery.Set(spec.Name, value)\n\t\t\tcase "cookie":\n\t\t\t\tcookies = append(cookies, spec.Name+"="+url.QueryEscape(value))\n\t\t\tdefault:\n\t\t\t\theaders[spec.Name] = value\n\t\t\t}\n\t\tcase "bearer":\n\t\t\tif auth.Bearer != nil {\n\t\t\t\theaders["Authorization"] = "Bearer " + resolveToken(auth.Bearer)\n\t\t\t}\n\t\tdefault:\n\t\t\tif auth.Basic != nil {\n\t\t\t\ttoken := base64.StdEncoding.EncodeToString([]byte(auth.Basic.Username + ":" + auth.Basic.Password))\n\t\t\t\theaders["Authorization"] = "Basic " + token\n\t\t\t}\n\t\t}\n\t}\n\tif len(cookies) > 0 {\n\t\theaders["Cookie"] = strings.Join(cookies, "; ")\n\t}\n\treturn headers, query\n}\n\n// buildURL substitutes {param} path placeholders with percent-encoded values.\nfunc buildURL(serverURL, path string, pathParams map[string]string) string {\n\tfilled := path\n\tfor name, value := range pathParams {\n\t\tfilled = strings.ReplaceAll(filled, "{"+name+"}", url.PathEscape(value))\n\t}\n\treturn strings.TrimRight(serverURL, "/") + filled\n}\n\nvar transientStatus = map[int]bool{408: true, 429: true, 500: true, 502: true, 503: true, 504: true}\n\nfunc defaultRetryOn(method string, headers map[string]string, resp *http.Response, err error) bool {\n\tsafe := false\n\tswitch strings.ToUpper(method) {\n\tcase "GET", "HEAD", "PUT", "DELETE", "OPTIONS":\n\t\tsafe = true\n\t}\n\tif _, ok := headers["Idempotency-Key"]; ok {\n\t\tsafe = true\n\t}\n\tif !safe {\n\t\treturn false\n\t}\n\tif err != nil {\n\t\treturn true\n\t}\n\treturn resp != nil && transientStatus[resp.StatusCode]\n}\n\nfunc retryDelay(retry RetryConfig, attempt int, retryAfter string) time.Duration {\n\tif retryAfter != "" {\n\t\tif seconds, err := strconv.ParseFloat(retryAfter, 64); err == nil {\n\t\t\treturn time.Duration(seconds * float64(time.Second))\n\t\t}\n\t}\n\tbase := retry.RetryDelay\n\tif base == 0 {\n\t\tbase = time.Second\n\t}\n\traw := base\n\tif retry.RetryStrategy != "fixed" {\n\t\traw = base * time.Duration(1<<(attempt-1))\n\t}\n\tif retry.NoJitter {\n\t\treturn raw\n\t}\n\treturn time.Duration(rand.Int63n(int64(raw) + 1))\n}\n\ntype requestSpec struct {\n\tOperationID string\n\tMethod string\n\tURL string\n\tHeaders map[string]string\n\tQuery url.Values\n\tBody io.Reader\n\tContentType string\n\tTimeout time.Duration\n\tRetry *RetryConfig\n\tIdempotencyKey string\n\t// bodyBytes is retained so retries can replay the body.\n\tbodyBytes []byte\n}\n\n// send is the request core: header merge, idempotency keys, the retry loop\n// (fresh timeout budget per attempt), and the middleware onion.\nfunc send(ctx context.Context, config *Config, spec requestSpec) (*http.Response, error) {\n\tretry := config.Retry\n\tif spec.Retry != nil {\n\t\tretry = *spec.Retry\n\t}\n\ttimeout := config.Timeout\n\tif spec.Timeout != 0 {\n\t\ttimeout = spec.Timeout\n\t}\n\theaders := map[string]string{}\n\tfor key, value := range config.Headers {\n\t\theaders[key] = value\n\t}\n\tfor key, value := range spec.Headers {\n\t\theaders[key] = value\n\t}\n\tmethod := strings.ToUpper(spec.Method)\n\tif (method == "POST" || method == "PATCH") && headers["Idempotency-Key"] == "" {\n\t\tif spec.IdempotencyKey != "" {\n\t\t\theaders["Idempotency-Key"] = spec.IdempotencyKey\n\t\t} else if config.IdempotencyKey != nil {\n\t\t\theaders["Idempotency-Key"] = config.IdempotencyKey()\n\t\t}\n\t}\n\thttpClient := config.HTTPClient\n\tif httpClient == nil {\n\t\thttpClient = http.DefaultClient\n\t}\n\tif spec.Body != nil {\n\t\tpayload, err := io.ReadAll(spec.Body)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tspec.bodyBytes = payload\n\t}\n\tfullURL := spec.URL\n\tif len(spec.Query) > 0 {\n\t\tseparator := "?"\n\t\tif strings.Contains(fullURL, "?") {\n\t\t\tseparator = "&"\n\t\t}\n\t\tfullURL += separator + spec.Query.Encode()\n\t}\n\tmaxAttempts := 1 + retry.Retries\n\tfor attempt := 1; ; attempt++ {\n\t\tattemptCtx := ctx\n\t\tvar cancel context.CancelFunc\n\t\tif timeout > 0 {\n\t\t\tattemptCtx, cancel = context.WithTimeout(ctx, timeout)\n\t\t}\n\t\tvar bodyReader io.Reader\n\t\tif spec.bodyBytes != nil {\n\t\t\tbodyReader = bytes.NewReader(spec.bodyBytes)\n\t\t}\n\t\treq, err := http.NewRequestWithContext(attemptCtx, method, fullURL, bodyReader)\n\t\tif err != nil {\n\t\t\tif cancel != nil {\n\t\t\t\tcancel()\n\t\t\t}\n\t\t\treturn nil, err\n\t\t}\n\t\tfor key, value := range headers {\n\t\t\treq.Header.Set(key, value)\n\t\t}\n\t\tif spec.ContentType != "" && spec.bodyBytes != nil {\n\t\t\treq.Header.Set("Content-Type", spec.ContentType)\n\t\t}\n\t\tfor _, mw := range config.Middleware {\n\t\t\tif mw.OnRequest != nil {\n\t\t\t\tmw.OnRequest(req)\n\t\t\t}\n\t\t}\n\t\tresp, err := httpClient.Do(req)\n\t\tshouldRetry := retry.RetryOn\n\t\tretryable := false\n\t\tif shouldRetry != nil {\n\t\t\tretryable = shouldRetry(attempt, resp, err)\n\t\t} else {\n\t\t\tretryable = defaultRetryOn(method, headers, resp, err)\n\t\t}\n\t\tif err != nil {\n\t\t\tif cancel != nil {\n\t\t\t\tcancel()\n\t\t\t}\n\t\t\ttimedOut := errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil\n\t\t\tif attempt < maxAttempts && retryable {\n\t\t\t\ttime.Sleep(retryDelay(retry, attempt, ""))\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif timedOut {\n\t\t\t\treturn nil, &TimeoutError{OperationID: spec.OperationID, Timeout: timeout, Attempt: attempt}\n\t\t\t}\n\t\t\treturn nil, err\n\t\t}\n\t\tfor i := len(config.Middleware) - 1; i >= 0; i-- {\n\t\t\tif config.Middleware[i].OnResponse != nil {\n\t\t\t\tconfig.Middleware[i].OnResponse(resp)\n\t\t\t}\n\t\t}\n\t\tif resp.StatusCode >= 400 && attempt < maxAttempts && retryable {\n\t\t\tafter := resp.Header.Get("Retry-After")\n\t\t\tio.Copy(io.Discard, resp.Body)\n\t\t\tresp.Body.Close()\n\t\t\tif cancel != nil {\n\t\t\t\tcancel()\n\t\t\t}\n\t\t\ttime.Sleep(retryDelay(retry, attempt, after))\n\t\t\tcontinue\n\t\t}\n\t\t// The response body outlives this call; tie the attempt context\'s lifetime to it.\n\t\tif cancel != nil {\n\t\t\tresp.Body = &cancelOnClose{ReadCloser: resp.Body, cancel: cancel}\n\t\t}\n\t\treturn resp, nil\n\t}\n}\n\ntype cancelOnClose struct {\n\tio.ReadCloser\n\tcancel context.CancelFunc\n}\n\nfunc (c *cancelOnClose) Close() error {\n\tc.cancel()\n\treturn c.ReadCloser.Close()\n}\n\n// decodeJSON decodes a response body into target; a nil target drains and closes.\nfunc decodeJSON(resp *http.Response, target any) error {\n\tdefer resp.Body.Close()\n\tif target == nil {\n\t\t_, err := io.Copy(io.Discard, resp.Body)\n\t\treturn err\n\t}\n\treturn json.NewDecoder(resp.Body).Decode(target)\n}\n\n// apiErrorFrom builds the structured error for a non-2xx response.\nfunc apiErrorFrom(resp *http.Response, requestURL string) error {\n\tdefer resp.Body.Close()\n\tvar body any\n\tdata, _ := io.ReadAll(resp.Body)\n\tif len(data) > 0 {\n\t\tif err := json.Unmarshal(data, &body); err != nil {\n\t\t\tbody = string(data)\n\t\t}\n\t}\n\treturn &APIError{URL: requestURL, Status: resp.StatusCode, StatusText: resp.Status, Body: body}\n}\n'; diff --git a/packages/client-generator/src/generators/__tests__/go-runtime-embed.test.ts b/packages/client-generator/src/generators/__tests__/go-runtime-embed.test.ts new file mode 100644 index 0000000000..4119247bfd --- /dev/null +++ b/packages/client-generator/src/generators/__tests__/go-runtime-embed.test.ts @@ -0,0 +1,32 @@ +import { spawnSync } from 'node:child_process'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { GO_RUNTIME_SOURCE } from '../../emitters/go-runtime-sources.js'; + +const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const hasGo = spawnSync('go', ['version']).status === 0; + +describe('GO_RUNTIME_SOURCE (the embedded Go runtime)', () => { + it('embeds the load-bearing declarations', () => { + for (const declaration of [ + 'type APIError struct', + 'type TimeoutError struct', + 'func resolveAuth(', + 'func buildURL(', + 'func send(ctx context.Context', + 'Idempotency-Key', + 'Retry-After', + ]) { + expect(GO_RUNTIME_SOURCE).toContain(declaration); + } + }); + + it.skipIf(!hasGo)('the runtime module passes go vet', () => { + const result = spawnSync('go', ['vet', './...'], { + cwd: join(pkgRoot, 'go-runtime'), + encoding: 'utf-8', + }); + expect(result.status, result.stderr).toBe(0); + }); +}); From d84f0774d39fb0be82c17cdbb1d5eb13fb618e2a Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 12:25:47 +0300 Subject: [PATCH 017/211] chore(client-generator): format-stable Go runtime embed --- packages/client-generator/scripts/generate-runtime-sources.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/client-generator/scripts/generate-runtime-sources.mjs b/packages/client-generator/scripts/generate-runtime-sources.mjs index f8ab057874..48f2b4904d 100644 --- a/packages/client-generator/scripts/generate-runtime-sources.mjs +++ b/packages/client-generator/scripts/generate-runtime-sources.mjs @@ -105,7 +105,8 @@ writeFileSync( goOut, [ '// GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`.', - `export const GO_RUNTIME_SOURCE = ${toStringLiteral(goSource)};`, + // oxfmt (printWidth 100) wraps the over-width const onto a continuation line. + `export const GO_RUNTIME_SOURCE =\n ${toStringLiteral(goSource)};`, '', ].join('\n') ); From b20f58904082077911e1eb3c63d15e9da269e554 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 12:32:08 +0300 Subject: [PATCH 018/211] =?UTF-8?q?feat(client-generator):=20Go=20model=20?= =?UTF-8?q?rendering=20=E2=80=94=20structs=20with=20json=20tags,=20typed-c?= =?UTF-8?q?onst=20enums,=20union=20dispatchers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/generators/__tests__/go.test.ts | 137 ++++++++++++ .../client-generator/src/generators/go.ts | 206 ++++++++++++++++++ 2 files changed, 343 insertions(+) create mode 100644 packages/client-generator/src/generators/__tests__/go.test.ts create mode 100644 packages/client-generator/src/generators/go.ts diff --git a/packages/client-generator/src/generators/__tests__/go.test.ts b/packages/client-generator/src/generators/__tests__/go.test.ts new file mode 100644 index 0000000000..2f863967d4 --- /dev/null +++ b/packages/client-generator/src/generators/__tests__/go.test.ts @@ -0,0 +1,137 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js'; +import { renderGoModels } from '../go.js'; + +const hasGo = spawnSync('go', ['version']).status === 0; + +/** Assert the rendered source is compilable Go (skipped without the toolchain). */ +function expectGoCompiles(source: string): void { + if (!hasGo) return; + const dir = mkdtempSync(join(tmpdir(), 'go-render-')); + try { + writeFileSync(join(dir, 'go.mod'), 'module render.test\n\ngo 1.21\n'); + writeFileSync(join(dir, 'models.go'), source); + const result = spawnSync('go', ['build', './...'], { cwd: dir, encoding: 'utf-8' }); + expect(result.status, result.stderr).toBe(0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +const STRING: SchemaModel = { kind: 'scalar', scalar: 'string' }; +const INT: SchemaModel = { kind: 'scalar', scalar: 'integer' }; + +function model(schemas: Record): ApiModel { + return { + title: 'Cafe', + version: '1.0.0', + services: [], + schemas: Object.entries(schemas).map(([name, schema]) => ({ name, schema })), + securitySchemes: [], + } as unknown as ApiModel; +} + +describe('renderGoModels', () => { + it('renders structs — required as value fields, optional as pointers with omitempty tags', () => { + const out = renderGoModels( + model({ + Order: { + kind: 'object', + description: 'One placed order.', + properties: [ + { name: 'id', schema: STRING, required: true }, + { name: 'quantity', schema: INT, required: true }, + { name: 'note', schema: STRING, required: false }, + ], + }, + }) + ); + expect(out).toContain('// Order — One placed order.'); + expect(out).toContain('type Order struct {'); + expect(out).toContain('Id string `json:"id"`'); + expect(out).toContain('Quantity int64 `json:"quantity"`'); + expect(out).toContain('Note *string `json:"note,omitempty"`'); + expectGoCompiles(out); + }); + + it('flattens allOf; json tags carry wire names for sanitized fields', () => { + const out = renderGoModels( + model({ + Base: { kind: 'object', properties: [{ name: 'offset', schema: INT, required: false }] }, + Page: { + kind: 'intersection', + members: [ + { kind: 'ref', name: 'Base' }, + { + kind: 'object', + properties: [ + { name: 'items', schema: { kind: 'array', items: STRING }, required: true }, + { name: 'go', schema: STRING, required: true }, // Go keyword as a wire name + ], + }, + ], + }, + }) + ); + expect(out).toContain('type Page struct {'); + expect(out).toContain('Items []string `json:"items"`'); + // The exported field name is always usable; the tag keeps the exact wire name. + expect(out).toContain('`json:"go"`'); + expectGoCompiles(out); + }); + + it('renders named enums as typed consts and discriminated unions with an unmarshal dispatcher', () => { + const out = renderGoModels( + model({ + Status: { kind: 'enum', values: ['in-progress', 'done'], scalar: 'string' }, + Cat: { kind: 'object', properties: [] }, + Dog: { kind: 'object', properties: [] }, + Pet: { + kind: 'union', + members: [ + { kind: 'ref', name: 'Cat' }, + { kind: 'ref', name: 'Dog' }, + ], + discriminator: { + propertyName: 'petType', + mapping: [ + { value: 'cat', schemaName: 'Cat' }, + { value: 'dog', schemaName: 'Dog' }, + ], + }, + }, + }) + ); + expect(out).toContain('type Status string'); + expect(out).toContain('StatusInProgress Status = "in-progress"'); + expect(out).toContain('type Pet = any'); + expect(out).toContain('func UnmarshalPet(data []byte) (Pet, error)'); + expect(out).toContain('case "cat":'); + expectGoCompiles(out); + }); + + it('maps nullability and records to pointers and maps', () => { + const out = renderGoModels( + model({ + Thing: { + kind: 'object', + properties: [ + { + name: 'tag', + schema: { kind: 'union', members: [STRING, { kind: 'null' }] }, + required: true, + }, + { name: 'meta', schema: { kind: 'record', value: STRING }, required: true }, + ], + }, + }) + ); + expect(out).toContain('Tag *string `json:"tag"`'); + expect(out).toContain('Meta map[string]string `json:"meta"`'); + expectGoCompiles(out); + }); +}); diff --git a/packages/client-generator/src/generators/go.ts b/packages/client-generator/src/generators/go.ts new file mode 100644 index 0000000000..bfff7f978a --- /dev/null +++ b/packages/client-generator/src/generators/go.ts @@ -0,0 +1,206 @@ +// The built-in `go` generator — the second non-TypeScript library entry, +// authored with the language-neutral toolkit only (same dogfooding invariant as +// the python generator, pinned by its guard test). Output is a single +// stdlib-only Go file: structs with json tags, typed-const enums, discriminated +// unions with unmarshal dispatchers, and a Client over the embedded runtime. + +import { + casing, + CodeWriter, + discriminatorCases, + docText, + enumValues, + flattenAllOf, + identifierFor, + isNullable, + RESERVED_WORDS, + unwrapNullable, +} from '../authoring/index.js'; +import type { ApiModel, PropertyModel, SchemaModel } from '../intermediate-representation/model.js'; + +const GO = RESERVED_WORDS.go; + +/** An exported Go identifier (PascalCase; keywords can't collide since these start uppercase). */ +function exported(name: string): string { + return identifierFor(name, { style: 'pascal', reserved: GO }); +} + +/** The Go type for a schema; `required=false` optionals become pointers at the field site. */ +export function goType(schema: SchemaModel): string { + if (isNullable(schema)) { + const inner = goType(unwrapNullable(schema)); + return inner.startsWith('*') || inner === 'any' ? inner : `*${inner}`; + } + switch (schema.kind) { + case 'scalar': + return { string: 'string', integer: 'int64', number: 'float64', boolean: 'bool' }[ + schema.scalar + ]; + case 'array': + return `[]${goType(schema.items)}`; + case 'record': + return `map[string]${goType(schema.value)}`; + case 'ref': + return exported(schema.name); + case 'literal': + return typeof schema.value === 'string' + ? 'string' + : typeof schema.value === 'boolean' + ? 'bool' + : 'float64'; + case 'enum': + // Anonymous (inline) enums keep the wire scalar; only NAMED enums get types. + return { string: 'string', integer: 'int64', number: 'float64', boolean: 'bool' }[ + schema.scalar + ]; + case 'omit': + // Go has no Omit; the base struct is the honest annotation (readOnly + // fields are server-managed and simply omitted from requests). + return exported(schema.base); + case 'union': + case 'null': + case 'object': + case 'intersection': + case 'unknown': + return 'any'; + } +} + +function writeDocComment(writer: CodeWriter, name: string, description?: string): void { + const lines = docText(description); + if (lines.length === 0) return; + writer.line(`// ${name} — ${lines[0]}`); + for (const line of lines.slice(1)) writer.line(`// ${line}`); +} + +function writeStruct( + writer: CodeWriter, + name: string, + properties: PropertyModel[], + description?: string +): void { + writeDocComment(writer, exported(name), description); + writer.block( + `type ${exported(name)} struct {`, + () => { + for (const property of properties) { + const field = exported(property.name); + let fieldType = goType(property.schema); + 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"\``; + } + writer.line(`${field} ${fieldType} ${tag}`); + } + }, + '}' + ); + writer.blank(); +} + +/** Render every named schema: typed-const enums, structs (allOf flattened), union dispatchers. */ +export function renderGoModels(model: ApiModel): string { + const writer = new CodeWriter('\t'); + writer.line('package client'); + writer.blank(); + const needsJSON = model.schemas.some( + ({ schema }) => discriminatorCases(schema, model) !== undefined + ); + if (needsJSON) { + writer.line('import "encoding/json"'); + writer.blank(); + } + + for (const { name, schema } of model.schemas) { + const asEnum = enumValues(schema); + if (asEnum !== undefined) { + const base = asEnum.scalar === 'string' ? 'string' : 'int64'; + writeDocComment(writer, exported(name), schema.description); + writer.line(`type ${exported(name)} ${base}`); + writer.blank(); + writer.block( + 'const (', + () => { + asEnum.values.forEach((value) => { + const member = exported(name) + casing.pascal(String(value)); + writer.line(`${member} ${exported(name)} = ${JSON.stringify(value)}`); + }); + }, + ')' + ); + writer.blank(); + continue; + } + if (schema.kind === 'object' || schema.kind === 'intersection') { + const flat = flattenAllOf(schema, model); + if (flat !== undefined) { + writeStruct(writer, name, flat.properties, 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(', '); + writer.line(`// ${typeName} is a discriminated union ("${cases.property}"): ${table}.`); + writer.line(`type ${typeName} = any`); + writer.blank(); + writer.line( + `// Unmarshal${typeName} decodes into the member selected by "${cases.property}".` + ); + writer.block( + `func Unmarshal${typeName}(data []byte) (${typeName}, error) {`, + () => { + writer.block( + 'var probe struct {', + () => { + writer.line(`Discriminant string \`json:"${cases.property}"\``); + }, + '}' + ); + writer.block( + 'if err := json.Unmarshal(data, &probe); err != nil {', + () => { + writer.line('return nil, err'); + }, + '}' + ); + writer.block( + 'switch probe.Discriminant {', + () => { + for (const entry of cases.cases) { + writer.block(`case ${JSON.stringify(entry.value)}:`, () => { + writer.line(`var value ${exported(entry.schemaName)}`); + writer.line('err := json.Unmarshal(data, &value)'); + writer.line('return value, err'); + }); + } + }, + '}' + ); + writer.line('var fallback any'); + writer.line('err := json.Unmarshal(data, &fallback)'); + writer.line('return fallback, err'); + }, + '}' + ); + writer.blank(); + continue; + } + // Everything else (plain unions, scalar aliases, records) becomes a type alias. + writeDocComment(writer, exported(name), schema.description); + writer.line(`type ${exported(name)} = ${goType(schema)}`); + writer.blank(); + } + return writer.toString(); +} From 0296ef459ee1c9fed1d2eb00e959f43da507c0b6 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 12:36:23 +0300 Subject: [PATCH 019/211] =?UTF-8?q?feat(client-generator):=20built-in=20go?= =?UTF-8?q?=20generator=20=E2=80=94=20client=20assembly,=20code=20samples,?= =?UTF-8?q?=20shared=20dogfooding=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/utils/generate-client-telemetry.ts | 1 + .../src/generators/__tests__/go.test.ts | 146 +++++++- ...ng.test.ts => language-dogfooding.test.ts} | 12 +- .../client-generator/src/generators/go.ts | 336 +++++++++++++++++- .../client-generator/src/generators/index.ts | 2 + .../client-generator/src/generators/meta.ts | 4 + .../client-generator/src/generators/types.ts | 3 +- 7 files changed, 495 insertions(+), 9 deletions(-) rename packages/client-generator/src/generators/__tests__/{python-dogfooding.test.ts => language-dogfooding.test.ts} (82%) diff --git a/packages/cli/src/utils/generate-client-telemetry.ts b/packages/cli/src/utils/generate-client-telemetry.ts index b59f2dbe40..6865b95ce0 100644 --- a/packages/cli/src/utils/generate-client-telemetry.ts +++ b/packages/cli/src/utils/generate-client-telemetry.ts @@ -25,6 +25,7 @@ export const BUILTIN_GENERATOR_NAMES = new Set([ 'transformers', 'mock', 'python', + 'go', ]); const IMPORT_RE = diff --git a/packages/client-generator/src/generators/__tests__/go.test.ts b/packages/client-generator/src/generators/__tests__/go.test.ts index 2f863967d4..9f55012d48 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 { renderGoModels } from '../go.js'; +import { goGenerator, renderGoModels } from '../go.js'; const hasGo = spawnSync('go', ['version']).status === 0; @@ -135,3 +135,147 @@ describe('renderGoModels', () => { expectGoCompiles(out); }); }); + +const CAFE: ApiModel = { + title: 'Cafe', + version: '1.0.0', + serverUrl: 'https://api.cafe.example', + services: [ + { + name: 'Orders', + operations: [ + { + name: 'listOrders', + specName: 'listOrders', + method: 'get', + path: '/orders', + tags: ['Orders'], + pathParams: [], + queryParams: [ + { name: 'after', in: 'query', required: false, schema: STRING }, + { name: 'limit', in: 'query', required: false, schema: INT }, + ], + headerParams: [], + cookieParams: [], + security: [['BearerAuth']], + paginationExtension: { + style: 'cursor', + cursorParam: 'after', + nextCursor: '/next', + items: '/items', + }, + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'ref', name: 'OrderPage' }, + }, + ], + errorResponses: [], + }, + { + name: 'getOrder', + specName: 'getOrder', + method: 'get', + path: '/orders/{orderId}', + tags: ['Orders'], + pathParams: [{ name: 'orderId', in: 'path', required: true, schema: STRING }], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'ref', name: 'Order' }, + }, + ], + errorResponses: [], + }, + { + name: 'createOrder', + specName: 'createOrder', + method: 'post', + path: '/orders', + tags: ['Orders'], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + requestBody: { contentType: 'application/json', schema: { kind: 'ref', name: 'Order' } }, + successResponses: [ + { + status: '201', + contentType: 'application/json', + schema: { kind: 'ref', name: 'Order' }, + }, + ], + errorResponses: [], + }, + ], + }, + ], + schemas: [ + { + name: 'Order', + schema: { kind: 'object', properties: [{ name: 'id', schema: STRING, required: true }] }, + }, + { + name: 'OrderPage', + schema: { + kind: 'object', + properties: [ + { + name: 'items', + schema: { kind: 'array', items: { kind: 'ref', name: 'Order' } }, + required: true, + }, + ], + }, + }, + ], + securitySchemes: [{ key: 'BearerAuth', kind: 'bearer' }], +} as unknown as ApiModel; + +function generateGo(): string { + const files = goGenerator({ + model: CAFE, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: {}, + }); + expect(files).toHaveLength(1); + expect(files[0].path).toBe('/out/client.go'); + return files[0].content; +} + +describe('goGenerator (full client assembly)', () => { + it('renders (T, error) methods over the operations table with typed params structs', () => { + const out = generateGo(); + expect(out).toContain('type Client struct {'); + expect(out).toContain('func New(config Config) *Client {'); + expect(out).toContain('type ListOrdersParams struct {'); + expect(out).toContain('After *string'); + expect(out).toContain( + 'func (c *Client) ListOrders(ctx context.Context, params *ListOrdersParams) (OrderPage, error) {' + ); + expect(out).toContain( + 'func (c *Client) GetOrder(ctx context.Context, orderId string) (Order, error) {' + ); + expect(out).toContain( + 'func (c *Client) CreateOrder(ctx context.Context, body Order) (Order, error) {' + ); + expect(out).toContain('return out, apiErrorFrom(resp, requestURL)'); + }); + + it('assembles one compilable file: models + embedded runtime + operations table', () => { + const out = generateGo(); + expect(out).toContain('var operations = map[string]operationMeta{'); + expect(out).toContain('"listOrders": {'); + expect(out).toContain('func send(ctx context.Context'); // embedded runtime + expect((out.match(/^package client$/gm) ?? []).length).toBe(1); + expectGoCompiles(out); + }); +}); diff --git a/packages/client-generator/src/generators/__tests__/python-dogfooding.test.ts b/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts similarity index 82% rename from packages/client-generator/src/generators/__tests__/python-dogfooding.test.ts rename to packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts index 382829b3c2..780e46ed77 100644 --- a/packages/client-generator/src/generators/__tests__/python-dogfooding.test.ts +++ b/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts @@ -10,17 +10,17 @@ import { fileURLToPath } from 'node:url'; const ALLOWED_SPECIFIERS = new Set([ '../authoring/index.js', '../emitters/python-runtime-sources.js', // pure embedded strings, generated at prepare time + '../emitters/go-runtime-sources.js', '../intermediate-representation/model.js', // type-only IR shapes './types.js', // the generator contract ]); -const source = readFileSync( - resolve(dirname(fileURLToPath(import.meta.url)), '../python.ts'), - 'utf-8' -); - -describe('python generator dogfooding invariant', () => { +describe.each(['python.ts', 'go.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)); diff --git a/packages/client-generator/src/generators/go.ts b/packages/client-generator/src/generators/go.ts index bfff7f978a..4f4b558977 100644 --- a/packages/client-generator/src/generators/go.ts +++ b/packages/client-generator/src/generators/go.ts @@ -16,7 +16,14 @@ import { RESERVED_WORDS, unwrapNullable, } from '../authoring/index.js'; -import type { ApiModel, PropertyModel, SchemaModel } from '../intermediate-representation/model.js'; +import { GO_RUNTIME_SOURCE } from '../emitters/go-runtime-sources.js'; +import type { + ApiModel, + OperationModel, + PropertyModel, + SchemaModel, +} from '../intermediate-representation/model.js'; +import type { CodeSample, Generator, SampleContext } from './types.js'; const GO = RESERVED_WORDS.go; @@ -204,3 +211,330 @@ export function renderGoModels(model: ApiModel): string { } return writer.toString(); } + +/** The operation's primary JSON success schema, or undefined for void/no-body ops. */ +function successSchema(op: OperationModel): SchemaModel | undefined { + return op.successResponses.find((r) => r.contentType.toLowerCase().includes('json'))?.schema; +} + +/** Go composite literal for one operation's security OR-alternatives. */ +function goSecurityLiteral(op: OperationModel, model: ApiModel): string | undefined { + const alternatives = op.security + .map((alternative) => + alternative.flatMap((key): string[] => { + const scheme = model.securitySchemes.find((s) => s.key === key); + if (scheme === undefined) return []; + if (scheme.kind === 'bearer' || scheme.kind === 'basic') { + return [`{Scheme: ${JSON.stringify(key)}, Kind: ${JSON.stringify(scheme.kind)}}`]; + } + const name = + scheme.kind === 'apiKeyHeader' + ? scheme.headerName + : scheme.kind === 'apiKeyQuery' + ? scheme.paramName + : scheme.cookieName; + const location = + scheme.kind === 'apiKeyHeader' + ? 'header' + : scheme.kind === 'apiKeyQuery' + ? 'query' + : 'cookie'; + return [ + `{Scheme: ${JSON.stringify(key)}, Kind: "apiKey", Name: ${JSON.stringify(name)}, In: ${JSON.stringify(location)}}`, + ]; + }) + ) + .filter((alternative) => alternative.length > 0); + if (alternatives.length === 0) return undefined; + return `[][]SecuritySpec{${alternatives.map((specs) => `{${specs.join(', ')}}`).join(', ')}}`; +} + +/** Every operation with its collision-free exported Go method name. */ +function goOperationIdents(model: ApiModel): Array<{ op: OperationModel; ident: string }> { + const used = new Set(); + const out: Array<{ op: OperationModel; ident: string }> = []; + for (const service of model.services) { + for (const op of service.operations) { + let ident = exported(op.name); + let suffix = 2; + while (used.has(ident)) ident = `${exported(op.name)}${suffix++}`; + used.add(ident); + out.push({ op, ident }); + } + } + return out; +} + +/** A query-value expression formatted to string for url.Values. */ +function goQueryFormat(expr: string, type: string): string { + if (type === 'string') return expr; + 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})`; +} + +/** Strip the package clause and import lines/blocks so a section stitches into one file. */ +function stripHeader(source: string): string { + const lines = source.split('\n'); + const out: string[] = []; + let inImportBlock = false; + for (const line of lines) { + if (line.startsWith('package ')) continue; + if (line.startsWith('import (')) { + inImportBlock = true; + continue; + } + if (inImportBlock) { + if (line.startsWith(')')) inImportBlock = false; + continue; + } + if (line.startsWith('import ')) continue; + out.push(line); + } + return out.join('\n').trim(); +} + +function writeGoMethod(writer: CodeWriter, op: OperationModel, ident: string): void { + const pathArgs = op.pathParams.map((param) => ({ + param, + go: identifierFor(param.name, { style: 'camel', reserved: GO }), + type: goType(param.schema), + })); + const hasParams = op.queryParams.length > 0; + const success = successSchema(op); + const returnType = success === undefined ? undefined : goType(success); + const args = [ + 'ctx context.Context', + ...pathArgs.map(({ go, type }) => `${go} ${type}`), + ...(op.requestBody ? [`body ${goType(op.requestBody.schema)}`] : []), + ...(hasParams ? [`params *${ident}Params`] : []), + ]; + const returns = returnType === undefined ? 'error' : `(${returnType}, error)`; + const fail = (errExpr: string) => + returnType === undefined ? `return ${errExpr}` : `return out, ${errExpr}`; + writeDocComment(writer, ident, op.summary); + writer.block( + `func (c *Client) ${ident}(${args.join(', ')}) ${returns} {`, + () => { + if (returnType !== undefined) writer.line(`var out ${returnType}`); + writer.line(`op := operations[${JSON.stringify(op.specName ?? op.name)}]`); + writer.line('authHeaders, query := resolveAuth(op.Security, c.config.Auth)'); + if (hasParams) { + writer.block( + 'if params != nil {', + () => { + for (const param of op.queryParams) { + const field = exported(param.name); + writer.block( + `if params.${field} != nil {`, + () => { + writer.line( + `query.Set(${JSON.stringify(param.name)}, ${goQueryFormat(`*params.${field}`, goType(param.schema))})` + ); + }, + '}' + ); + } + }, + '}' + ); + } + const pathDict = pathArgs + .map(({ param, go, type }) => `${JSON.stringify(param.name)}: ${goQueryFormat(go, type)}`) + .join(', '); + writer.line( + `requestURL := buildURL(c.config.ServerURL, op.Path, map[string]string{${pathDict}})` + ); + const specFields = [ + 'OperationID: op.ID', + 'Method: op.Method', + 'URL: requestURL', + 'Headers: authHeaders', + 'Query: query', + ]; + if (op.requestBody) { + writer.line('payload, err := json.Marshal(body)'); + writer.block( + 'if err != nil {', + () => { + writer.line(fail('err')); + }, + '}' + ); + specFields.push('Body: bytes.NewReader(payload)'); + specFields.push(`ContentType: ${JSON.stringify(op.requestBody.contentType)}`); + } + writer.line(`resp, err := send(ctx, &c.config, requestSpec{${specFields.join(', ')}})`); + writer.block( + 'if err != nil {', + () => { + writer.line(fail('err')); + }, + '}' + ); + writer.block( + 'if resp.StatusCode >= 400 {', + () => { + writer.line(fail('apiErrorFrom(resp, requestURL)')); + }, + '}' + ); + if (returnType === undefined) { + writer.line('return decodeJSON(resp, nil)'); + } else { + writer.block( + 'if err := decodeJSON(resp, &out); err != nil {', + () => { + writer.line('return out, err'); + }, + '}' + ); + writer.line('return out, nil'); + } + }, + '}' + ); + writer.blank(); +} + +/** The whole generated file: models + embedded runtime + operations table + Client. */ +export const goGenerator: Generator = ({ model, outputPath }) => { + const writer = new CodeWriter('\t'); + writer.line( + `// Code generated by @redocly/client-generator (go) from "${model.title}" ${model.version}. DO NOT EDIT.` + ); + writer.line( + '// Regenerate with `redocly generate-client`. Standard library only — zero dependencies.' + ); + writer.line('package client'); + writer.blank(); + // One merged import block: the runtime uses every entry; generated code uses a subset. + writer.block( + 'import (', + () => { + for (const spec of [ + 'bytes', + 'context', + 'encoding/base64', + 'encoding/json', + 'errors', + 'fmt', + 'io', + 'math/rand', + 'net/http', + 'net/url', + 'strconv', + 'strings', + 'time', + ]) { + writer.line(JSON.stringify(spec)); + } + }, + ')' + ); + writer.blank(); + + writer.line(stripHeader(renderGoModels(model))); + writer.blank(); + writer.line('// ─── Embedded runtime (@redocly/client-generator go runtime) ───'); + writer.line(stripHeader(GO_RUNTIME_SOURCE)); + writer.blank(); + + writer.block( + 'type operationMeta struct {', + () => { + writer.line('ID string'); + writer.line('Method string'); + writer.line('Path string'); + writer.line('Security [][]SecuritySpec'); + }, + '}' + ); + writer.blank(); + writer.block( + 'var operations = map[string]operationMeta{', + () => { + for (const { op } of goOperationIdents(model)) { + const id = op.specName ?? op.name; + const security = goSecurityLiteral(op, model); + const fields = [ + `ID: ${JSON.stringify(id)}`, + `Method: ${JSON.stringify(op.method.toUpperCase())}`, + `Path: ${JSON.stringify(op.path)}`, + ...(security !== undefined ? [`Security: ${security}`] : []), + ]; + writer.line(`${JSON.stringify(id)}: {${fields.join(', ')}},`); + } + }, + '}' + ); + writer.blank(); + + // Per-operation query-parameter structs (pointer fields: absent = not sent). + for (const { op, ident } of goOperationIdents(model)) { + if (op.queryParams.length === 0) continue; + writer.block( + `type ${ident}Params struct {`, + () => { + for (const param of op.queryParams) { + const fieldType = goType(param.schema); + writer.line( + `${exported(param.name)} ${fieldType.startsWith('*') ? fieldType : `*${fieldType}`}` + ); + } + }, + '}' + ); + writer.blank(); + } + + writeDocComment(writer, 'Client', `Client for ${model.title} (${model.version}).`); + writer.block( + 'type Client struct {', + () => { + writer.line('config Config'); + }, + '}' + ); + writer.blank(); + writer.block( + 'func New(config Config) *Client {', + () => { + writer.block( + 'if config.ServerURL == "" {', + () => { + writer.line(`config.ServerURL = ${JSON.stringify(model.serverUrl ?? '')}`); + }, + '}' + ); + writer.line('return &Client{config: config}'); + }, + '}' + ); + writer.blank(); + + for (const { op, ident } of goOperationIdents(model)) { + writeGoMethod(writer, op, ident); + } + + return [{ path: outputPath.replace(/\.[^.\\/]+$/, '.go'), content: writer.toString() }]; +}; + +/** One idiomatic Go call per operation — feeds `x-codeSamples` for docs. */ +export function goSample(op: OperationModel, _ctx: SampleContext): CodeSample { + const ident = exported(op.name); + const args = [ + 'ctx', + ...op.pathParams.map( + (param) => `"<${identifierFor(param.name, { style: 'camel', reserved: GO })}>"` + ), + ...(op.requestBody ? [`${goType(op.requestBody.schema)}{ /* … */ }`] : []), + ...(op.queryParams.length > 0 ? ['nil'] : []), + ]; + return { + lang: 'go', + label: 'Go SDK', + source: `client := client.New(client.Config{})\nresult, err := client.${ident}(${args.join(', ')})\n`, + }; +} diff --git a/packages/client-generator/src/generators/index.ts b/packages/client-generator/src/generators/index.ts index 9c12b1edfc..da77edef45 100644 --- a/packages/client-generator/src/generators/index.ts +++ b/packages/client-generator/src/generators/index.ts @@ -1,4 +1,5 @@ import type { EmitOptions } from '../emitters/emit-options.js'; +import { goGenerator, goSample } from './go.js'; import { BUILTIN_META, validateSelection, type BuiltinMeta } from './meta.js'; import { mockGenerator } from './mock.js'; import { pythonGenerator, pythonSample } from './python.js'; @@ -32,6 +33,7 @@ const RUNS: Record> = swr: { run: swrGenerator }, mock: { run: mockGenerator }, python: { run: pythonGenerator, sample: pythonSample }, + go: { run: goGenerator, sample: goSample }, }; const GENERATORS = Object.fromEntries( diff --git a/packages/client-generator/src/generators/meta.ts b/packages/client-generator/src/generators/meta.ts index 04156a58bb..9149d5448e 100644 --- a/packages/client-generator/src/generators/meta.ts +++ b/packages/client-generator/src/generators/meta.ts @@ -57,6 +57,10 @@ export const BUILTIN_META: Record = { load: () => import('./python.js').then((m) => ({ run: m.pythonGenerator, sample: m.pythonSample })), }, + // go emits a standalone full Go SDK (stdlib-only) — no TypeScript involved. + go: { + load: () => import('./go.js').then((m) => ({ run: m.goGenerator, sample: m.goSample })), + }, }; /** diff --git a/packages/client-generator/src/generators/types.ts b/packages/client-generator/src/generators/types.ts index 85c74d7ad3..e16fffef9b 100644 --- a/packages/client-generator/src/generators/types.ts +++ b/packages/client-generator/src/generators/types.ts @@ -27,7 +27,8 @@ export type GeneratorName = | 'swr' | 'transformers' | 'mock' - | 'python'; + | 'python' + | 'go'; /** Everything a generator needs to produce its files. */ export type GeneratorInput = { From 481858bcf8c5f5b8d74e16e790216d15b5644867 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 12:38:45 +0300 Subject: [PATCH 020/211] feat(client-generator): Go e2e harness, docs, and changeset --- .changeset/go-generator.md | 6 ++ docs/@v2/commands/generate-client.md | 2 +- docs/@v2/configuration/reference/client.md | 2 +- docs/@v2/guides/use-generated-client.md | 11 ++++ .../generate-client/go-consumer/.gitignore | 2 + tests/e2e/generate-client/go-consumer/go.mod | 3 + tests/e2e/generate-client/go-consumer/main.go | 45 ++++++++++++++ tests/e2e/generate-client/go.test.ts | 60 +++++++++++++++++++ 8 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 .changeset/go-generator.md create mode 100644 tests/e2e/generate-client/go-consumer/.gitignore create mode 100644 tests/e2e/generate-client/go-consumer/go.mod create mode 100644 tests/e2e/generate-client/go-consumer/main.go create mode 100644 tests/e2e/generate-client/go.test.ts diff --git a/.changeset/go-generator.md b/.changeset/go-generator.md new file mode 100644 index 0000000000..e1b4f2f455 --- /dev/null +++ b/.changeset/go-generator.md @@ -0,0 +1,6 @@ +--- +'@redocly/client-generator': minor +'@redocly/cli': minor +--- + +Added a built-in `go` generator — a self-contained, zero-dependency Go SDK over the standard library with typed structs, enums, discriminated-union dispatchers, a context-aware client with `(T, error)` methods, auth, retries, timeouts, idempotency keys, and middleware, plus Go `x-codeSamples`. Also added two language-neutral authoring helpers, `schemaAtPointer` and `paginationRuleFor`, shared by every generator. diff --git a/docs/@v2/commands/generate-client.md b/docs/@v2/commands/generate-client.md index cef481bef9..d62da582c4 100644 --- a/docs/@v2/commands/generate-client.md +++ b/docs/@v2/commands/generate-client.md @@ -40,7 +40,7 @@ redocly generate-client [--help] [--version] | `--output-mode` | string | File layout. See [Choose an output mode](#choose-an-output-mode).
**Possible values:** `single`, `split`. Default value is `single`. | | `--runtime` | string | Where the client's engine lives. See [Choose a runtime](#choose-a-runtime).
**Possible values:** `inline`, `package`. Default value is `inline`. | | `--import-ext` | string | Extension in generated relative imports. See [Run with Node directly](../guides/use-generated-client.md#run-with-node-directly).
**Possible values:** `js` (the tsc/bundler convention), `ts` (for Node's built-in type stripping). Default value is `js`. | -| `--generator` | [string] | Generator to run — a built-in name (`tanstack-query` also has `-vue`/`-svelte`/`-solid` variants; `python` emits a full Python SDK) or a custom generator's path or package; repeat the flag to run several. Default value is `sdk`. See [Generators](../guides/use-generated-client.md#generators). | +| `--generator` | [string] | Generator to run — a built-in name (`tanstack-query` also has `-vue`/`-svelte`/`-solid` variants; `python`/`go` emit full Python and Go SDKs) or a custom generator's path or package; repeat the flag to run several. Default value is `sdk`. See [Generators](../guides/use-generated-client.md#generators). | | `--args-style` | string | How operation inputs are passed. See [Argument style](../guides/use-generated-client.md#argument-style).
**Possible values:** `flat`, `grouped`. Default value is `flat`. | | `--error-mode` | string | How operations report HTTP errors. See [Error handling](../guides/use-generated-client.md#error-handling).
**Possible values:** `throw`, `result`. Default value is `throw`. | | `--date-type` | string | Type of `date`/`date-time` fields; pair `Date` with the `transformers` generator.
**Possible values:** `string`, `Date`. Default value is `string`. | diff --git a/docs/@v2/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md index ffbe7d4c7e..b149572ba5 100644 --- a/docs/@v2/configuration/reference/client.md +++ b/docs/@v2/configuration/reference/client.md @@ -19,7 +19,7 @@ For runs without a configuration file, declare pagination per operation with the | Option | Type | Description | | ---------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `generators` | [string] | Generators to run, in order. Each entry is a built-in name (`sdk`, `zod`, `tanstack-query` — or its `-vue`/`-svelte`/`-solid` variants — `swr`, `mock`, `transformers`, `python`) or a custom generator's path or package name. | +| `generators` | [string] | Generators to run, in order. Each entry is a built-in name (`sdk`, `zod`, `tanstack-query` — or its `-vue`/`-svelte`/`-solid` variants — `swr`, `mock`, `transformers`, `python`, `go`) or a custom generator's path or package name. | | `outputMode` | string | File layout: `single` or `split`. | | `runtime` | string | Runtime distribution: `inline` or `package`. | | `importExt` | string | Extension in generated relative imports: `js` (default, for tsc and bundlers) or `ts` (for Node's built-in type stripping). | diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index d7ca305658..a44f7a1c60 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -41,6 +41,17 @@ for order in client.list_orders_items(limit=50): print(order) ``` +### Go SDK + +The `go` generator emits a self-contained `.go` — a full Go SDK over the standard library (zero dependencies, Go ≥ 1.21): +structs with `json` tags (allOf flattened, typed-const enums, discriminated-union unmarshal dispatchers), a `Client` with one `(T, error)` method per operation taking a `context.Context`, auth, retries with `Retry-After` and jittered backoff, per-attempt timeouts, idempotency keys, and middleware hooks. +Go's `(T, error)` returns are the error mode; `errorMode` does not change the output. + +```go +api := client.New(client.Config{Auth: client.Auth{Bearer: func() string { return "TOKEN" }}}) +order, err := api.GetOrder(ctx, "ord_123") +``` + ## Package runtime By default the runtime is embedded in the generated file, so the client is self-contained. diff --git a/tests/e2e/generate-client/go-consumer/.gitignore b/tests/e2e/generate-client/go-consumer/.gitignore new file mode 100644 index 0000000000..5d02d96ce0 --- /dev/null +++ b/tests/e2e/generate-client/go-consumer/.gitignore @@ -0,0 +1,2 @@ +client/ +smoke diff --git a/tests/e2e/generate-client/go-consumer/go.mod b/tests/e2e/generate-client/go-consumer/go.mod new file mode 100644 index 0000000000..60ca607154 --- /dev/null +++ b/tests/e2e/generate-client/go-consumer/go.mod @@ -0,0 +1,3 @@ +module smoke.test + +go 1.21 diff --git a/tests/e2e/generate-client/go-consumer/main.go b/tests/e2e/generate-client/go-consumer/main.go new file mode 100644 index 0000000000..64a5df4923 --- /dev/null +++ b/tests/e2e/generate-client/go-consumer/main.go @@ -0,0 +1,45 @@ +// Runtime smoke for the generated Go SDK, exercised against the same Node mock +// server the other consumers use. Built and run by go.test.ts with the server +// base URL as the only argument. +package main + +import ( + "context" + "errors" + "fmt" + "os" + + client "smoke.test/client" +) + +func main() { + ctx := context.Background() + api := client.New(client.Config{ServerURL: os.Args[1]}) + + // Typed call: the response decodes into the generated struct. + pet, err := api.GetPetById(ctx, 1) + if err != nil { + panic(err) + } + if pet.Name == "" { + panic("pet.Name should hydrate") + } + + // Collection + request body round-trips. + if _, err := api.ListPets(ctx, nil); err != nil { + panic(err) + } + if _, err := api.CreatePet(ctx, client.Pet{Name: "Smokey"}); err != nil { + panic(err) + } + + // A non-2xx returns the structured *APIError (a wrong base path 404s every route). + broken := client.New(client.Config{ServerURL: os.Args[1] + "/nowhere"}) + _, err = broken.GetPetById(ctx, 1) + var apiErr *client.APIError + if !errors.As(err, &apiErr) || apiErr.Status != 404 { + panic(fmt.Sprintf("expected a 404 APIError, got %v", err)) + } + + fmt.Println("GO_SMOKE_OK") +} diff --git a/tests/e2e/generate-client/go.test.ts b/tests/e2e/generate-client/go.test.ts new file mode 100644 index 0000000000..6739be3d91 --- /dev/null +++ b/tests/e2e/generate-client/go.test.ts @@ -0,0 +1,60 @@ +import { spawnSync, type ChildProcess } from 'node:child_process'; +import { existsSync, rmSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { generate, killServer, startServer } from './helpers.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixture = join(__dirname, 'fixtures/base.yaml'); +const consumerDir = join(__dirname, 'go-consumer'); +const generatedFile = join(consumerDir, 'client/client.go'); + +const SERVER_PORT = 3107; +const SERVER_BASE = `http://127.0.0.1:${SERVER_PORT}`; + +const hasGo = spawnSync('go', ['version']).status === 0; + +describe('generate-client go generator (end-to-end)', () => { + afterAll(() => { + rmSync(join(consumerDir, 'client'), { recursive: true, force: true }); + rmSync(join(consumerDir, 'smoke'), { force: true }); + }); + + it('generates a self-contained client.go from the CLI', () => { + generate(fixture, join(consumerDir, 'client/client.ts'), ['--generator', 'go']); + expect(existsSync(generatedFile)).toBe(true); + }); + + it.skipIf(!hasGo)('the generated client compiles (go build)', () => { + const result = spawnSync('go', ['build', '-o', 'smoke', '.'], { + cwd: consumerDir, + encoding: 'utf-8', + }); + expect(result.status, result.stderr).toBe(0); + }); + + it.skipIf(!hasGo)( + 'the compiled smoke runs real HTTP: hydration, bodies, APIError', + async () => { + let serverProcess: ChildProcess | undefined; + try { + serverProcess = await startServer( + join(__dirname, 'base-consumer/server.ts'), + join(__dirname, 'base-consumer'), + { BASE_SERVER_PORT: String(SERVER_PORT) }, + SERVER_BASE, + 'go-smoke-server' + ); + const result = spawnSync(join(consumerDir, 'smoke'), [SERVER_BASE], { + encoding: 'utf-8', + }); + expect(result.status, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`).toBe(0); + expect(result.stdout).toContain('GO_SMOKE_OK'); + } finally { + if (serverProcess) await killServer(serverProcess); + } + }, + 60_000 + ); +}); From c8a439a5ce7b90ab87945f64dbe586d485ac72ed Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 14:32:02 +0300 Subject: [PATCH 021/211] feat(client-generator): Go pagination, SSE, and multipart parity --- .changeset/go-generator.md | 2 +- docs/@v2/guides/use-generated-client.md | 10 +- .../client-generator/go-runtime/runtime.go | 395 ++++++++++++++++++ .../src/emitters/go-runtime-sources.ts | 2 +- .../src/generators/__tests__/go.test.ts | 62 +++ .../client-generator/src/generators/go.ts | 304 +++++++++++++- 6 files changed, 763 insertions(+), 12 deletions(-) diff --git a/.changeset/go-generator.md b/.changeset/go-generator.md index e1b4f2f455..6094b53e77 100644 --- a/.changeset/go-generator.md +++ b/.changeset/go-generator.md @@ -3,4 +3,4 @@ '@redocly/cli': minor --- -Added a built-in `go` generator — a self-contained, zero-dependency Go SDK over the standard library with typed structs, enums, discriminated-union dispatchers, a context-aware client with `(T, error)` methods, auth, retries, timeouts, idempotency keys, and middleware, plus Go `x-codeSamples`. Also added two language-neutral authoring helpers, `schemaAtPointer` and `paginationRuleFor`, shared by every generator. +Added a built-in `go` generator — a self-contained, zero-dependency Go SDK over the standard library with typed structs, enums, discriminated-union dispatchers, a context-aware client with `(T, error)` methods, auth, retries, timeouts, idempotency keys, middleware, pagination iterators (`Pages` / `Items` in `range`-over-func style), SSE streaming, and multipart bodies, plus Go `x-codeSamples`. Also added two language-neutral authoring helpers, `schemaAtPointer` and `paginationRuleFor`, shared by every generator. diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index a44f7a1c60..c70b652d93 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -44,12 +44,20 @@ for order in client.list_orders_items(limit=50): ### Go SDK The `go` generator emits a self-contained `.go` — a full Go SDK over the standard library (zero dependencies, Go ≥ 1.21): -structs with `json` tags (allOf flattened, typed-const enums, discriminated-union unmarshal dispatchers), a `Client` with one `(T, error)` method per operation taking a `context.Context`, auth, retries with `Retry-After` and jittered backoff, per-attempt timeouts, idempotency keys, and middleware hooks. +structs with `json` tags (allOf flattened, typed-const enums, discriminated-union unmarshal dispatchers), a `Client` with one `(T, error)` method per operation taking a `context.Context`, auth, retries with `Retry-After` and jittered backoff, per-attempt timeouts, idempotency keys, middleware hooks, pagination iterators (`Pages` / `Items`, `range`-over-func style), SSE streaming, and multipart bodies. Go's `(T, error)` returns are the error mode; `errorMode` does not change the output. +The iterators are `func(yield func(T, error) bool)` values: `for … range` over them needs Go ≥ 1.23; on 1.21–1.22 call them with a callback instead. ```go api := client.New(client.Config{Auth: client.Auth{Bearer: func() string { return "TOKEN" }}}) order, err := api.GetOrder(ctx, "ord_123") + +for order, err := range api.ListOrdersItems(ctx, nil) { + if err != nil { + break + } + fmt.Println(order.Id) +} ``` ## Package runtime diff --git a/packages/client-generator/go-runtime/runtime.go b/packages/client-generator/go-runtime/runtime.go index 478ed705d7..a086a8f9ab 100644 --- a/packages/client-generator/go-runtime/runtime.go +++ b/packages/client-generator/go-runtime/runtime.go @@ -15,6 +15,7 @@ import ( "fmt" "io" "math/rand" + "mime/multipart" "net/http" "net/url" "strconv" @@ -386,3 +387,397 @@ func apiErrorFrom(resp *http.Response, requestURL string) error { } return &APIError{URL: requestURL, Status: resp.StatusCode, StatusText: resp.Status, Body: body} } + +// ─── Pagination ─── + +// PaginationSpec mirrors the descriptor table's pagination entries. +type PaginationSpec struct { + Style string + Param string + NextCursor string + HasMore string + LimitParam string + Items string +} + +// resolvePointer walks an RFC 6901 JSON pointer over decoded JSON; nil on any miss. +func resolvePointer(data any, pointer string) any { + if pointer == "" { + return data + } + if !strings.HasPrefix(pointer, "/") { + return nil + } + current := data + for _, token := range strings.Split(pointer[1:], "/") { + key := strings.ReplaceAll(strings.ReplaceAll(token, "~1", "/"), "~0", "~") + switch typed := current.(type) { + case map[string]any: + current = typed[key] + case []any: + index, err := strconv.Atoi(key) + if err != nil || index < 0 || index >= len(typed) { + return nil + } + current = typed[index] + default: + return nil + } + if current == nil { + return nil + } + } + return current +} + +// reencode converts decoded JSON (maps/slices) into a typed value via a JSON round-trip. +func reencode(raw any, target any) error { + data, err := json.Marshal(raw) + if err != nil { + return err + } + return json.Unmarshal(data, target) +} + +type pageCall func(params url.Values) (any, *http.Response, error) + +// iterPages yields raw page JSON per the pagination spec — the same stop +// conditions and infinite-loop guards as the TypeScript runtime. The returned +// function is a range-over-func iterator (Go 1.23+) and plainly callable before that. +func iterPages(call pageCall, spec PaginationSpec, base url.Values) func(yield func(any, error) bool) { + return func(yield func(any, error) bool) { + switch spec.Style { + case "cursor": + var cursor any + if values, ok := base[spec.Param]; ok && len(values) > 0 { + cursor = values[0] + } + for { + params := cloneValues(base) + if cursor != nil { + params.Set(spec.Param, fmt.Sprint(cursor)) + } + page, _, err := call(params) + if err != nil { + yield(nil, err) + return + } + if !yield(page, nil) { + return + } + if spec.HasMore != "" { + if more, ok := resolvePointer(page, spec.HasMore).(bool); ok && !more { + return + } + } + next := resolvePointer(page, spec.NextCursor) + if next == nil || next == "" { + return + } + switch next.(type) { + case string, float64: + default: + yield(nil, fmt.Errorf("pagination cursor at %s is not a string or number", spec.NextCursor)) + return + } + if cursor != nil && fmt.Sprint(next) == fmt.Sprint(cursor) { + yield(nil, errors.New("pagination did not advance: the operation returned the same cursor twice")) + return + } + cursor = next + } + case "link": + params := cloneValues(base) + previous := "" + for { + page, resp, err := call(params) + if err != nil { + yield(nil, err) + return + } + if !yield(page, nil) { + return + } + target := linkNext(resp.Header.Get("Link")) + if target == "" { + return + } + pageURL := "" + if resp.Request != nil && resp.Request.URL != nil { + pageURL = resp.Request.URL.String() + } + baseURL, err := url.Parse(pageURL) + if err != nil || pageURL == "" { + baseURL, _ = url.Parse("http://relative.invalid") + } + targetURL, err := baseURL.Parse(target) + if err != nil { + yield(nil, err) + return + } + next := targetURL.String() + if next == previous || next == pageURL { + yield(nil, errors.New(`pagination did not advance: the Link rel="next" target repeats`)) + return + } + previous = next + params = cloneValues(base) + for key, values := range targetURL.Query() { + for _, value := range values { + params.Add(key, value) + } + } + } + default: // offset / page + position := 0 + if spec.Style == "page" { + position = 1 + } + if values, ok := base[spec.Param]; ok && len(values) > 0 && values[0] != "" { + if parsed, err := strconv.Atoi(values[0]); err == nil { + position = parsed + } + } + previousItems := "" + for { + params := cloneValues(base) + params.Set(spec.Param, strconv.Itoa(position)) + page, _, err := call(params) + if err != nil { + yield(nil, err) + return + } + items, _ := resolvePointer(page, spec.Items).([]any) + serialized := "" + if items != nil { + serialized = fmt.Sprint(items) + if serialized == previousItems { + yield(nil, errors.New("pagination did not advance: the operation returned the same page twice")) + return + } + } + if !yield(page, nil) { + return + } + if len(items) == 0 { + return + } + previousItems = serialized + if spec.Style == "page" { + position++ + } else { + position += len(items) + } + } + } + } +} + +func cloneValues(values url.Values) url.Values { + out := url.Values{} + for key, entries := range values { + for _, entry := range entries { + out.Add(key, entry) + } + } + return out +} + +func linkNext(header string) string { + if header == "" { + return "" + } + for _, entry := range strings.Split(header, ",") { + parts := strings.Split(entry, ";") + if len(parts) < 2 { + continue + } + target := strings.TrimSpace(parts[0]) + if !strings.HasPrefix(target, "<") || !strings.HasSuffix(target, ">") { + continue + } + for _, param := range parts[1:] { + trimmed := strings.TrimSpace(param) + if strings.HasPrefix(trimmed, "rel=") { + rel := strings.Trim(strings.TrimPrefix(trimmed, "rel="), `"`) + for _, kind := range strings.Fields(rel) { + if kind == "next" { + return strings.Trim(target, "<>") + } + } + } + } + } + return "" +} + +// ─── Server-Sent Events ─── + +// ServerSentEvent is one decoded event; Data is the raw text (or parsed JSON +// for operations that declare a JSON event stream). +type ServerSentEvent struct { + Event string + Data any + ID string + Retry int +} + +func parseSSEFrame(raw string, jsonData bool) (ServerSentEvent, bool, error) { + event := ServerSentEvent{Retry: -1} + sawField := false + var dataLines []string + normalized := strings.ReplaceAll(strings.ReplaceAll(raw, "\r\n", "\n"), "\r", "\n") + for _, line := range strings.Split(normalized, "\n") { + if line == "" || strings.HasPrefix(line, ":") { + continue + } + field, value, _ := strings.Cut(line, ":") + value = strings.TrimPrefix(value, " ") + sawField = true + switch field { + case "event": + event.Event = value + case "data": + dataLines = append(dataLines, value) + case "id": + event.ID = value + case "retry": + if parsed, err := strconv.Atoi(value); err == nil && parsed >= 0 && value != "" { + event.Retry = parsed + } + } + } + if !sawField { + return event, false, nil + } + text := strings.Join(dataLines, "\n") + event.Data = text + if jsonData && text != "" { + var parsed any + if err := json.Unmarshal([]byte(text), &parsed); err != nil { + return event, false, err + } + event.Data = parsed + } + return event, true, nil +} + +// iterSSE streams events, reconnecting on dropped connections with Last-Event-ID +// (a fresh open call = fresh auth); a 4xx/5xx or a bad JSON payload is definitive. +func iterSSE(open func(extraHeaders map[string]string) (*http.Response, error), jsonData bool) func(yield func(ServerSentEvent, error) bool) { + return func(yield func(ServerSentEvent, error) bool) { + lastEventID := "" + serverRetry := -1 + failures := 0 + for { + headers := map[string]string{"Accept": "text/event-stream"} + if lastEventID != "" { + headers["Last-Event-ID"] = lastEventID + } + resp, err := open(headers) + if err == nil && resp.StatusCode >= 400 { + yield(ServerSentEvent{}, apiErrorFrom(resp, "")) + return + } + if err == nil { + failures = 0 + buffer := "" + chunk := make([]byte, 4096) + clean := false + for { + n, readErr := resp.Body.Read(chunk) + buffer += string(chunk[:n]) + for { + frame, rest, found := strings.Cut(buffer, "\n\n") + if !found { + break + } + buffer = rest + event, ok, parseErr := parseSSEFrame(frame, jsonData) + if parseErr != nil { + resp.Body.Close() + yield(ServerSentEvent{}, parseErr) + return + } + if ok { + if event.ID != "" { + lastEventID = event.ID + } + if event.Retry >= 0 { + serverRetry = event.Retry + } + if !yield(event, nil) { + resp.Body.Close() + return + } + } + } + if readErr == io.EOF { + clean = true + break + } + if readErr != nil { + break + } + } + resp.Body.Close() + if clean { + if strings.TrimSpace(buffer) != "" { + if event, ok, parseErr := parseSSEFrame(buffer, jsonData); parseErr == nil && ok { + yield(event, nil) + } + } + return + } + } + failures++ + base := time.Second + if serverRetry >= 0 { + base = time.Duration(serverRetry) * time.Millisecond + } + delay := base * time.Duration(1<<(failures-1)) + if delay > 30*time.Second { + delay = 30 * time.Second + } + time.Sleep(time.Duration(rand.Int63n(int64(delay) + 1))) + } + } +} + +// ─── Multipart ─── + +// toMultipart splits a typed body into a multipart/form-data payload: []byte +// values upload as file parts, everything else as form fields (nested values +// JSON-encoded) — mirroring the TypeScript runtime's FormData serialization. +func toMultipart(body any) (string, io.Reader, error) { + var wire map[string]any + if err := reencode(body, &wire); err != nil { + return "", nil, err + } + buffer := &bytes.Buffer{} + writer := multipart.NewWriter(buffer) + for key, value := range wire { + switch typed := value.(type) { + case string: + if err := writer.WriteField(key, typed); err != nil { + return "", nil, err + } + case float64, bool: + if err := writer.WriteField(key, fmt.Sprint(typed)); err != nil { + return "", nil, err + } + default: + encoded, err := json.Marshal(typed) + if err != nil { + return "", nil, err + } + if err := writer.WriteField(key, string(encoded)); err != nil { + return "", nil, err + } + } + } + if err := writer.Close(); err != nil { + return "", nil, err + } + return writer.FormDataContentType(), buffer, nil +} diff --git a/packages/client-generator/src/emitters/go-runtime-sources.ts b/packages/client-generator/src/emitters/go-runtime-sources.ts index 5c23d31527..4e0d2a9a36 100644 --- a/packages/client-generator/src/emitters/go-runtime-sources.ts +++ b/packages/client-generator/src/emitters/go-runtime-sources.ts @@ -1,3 +1,3 @@ // GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`. export const GO_RUNTIME_SOURCE = - '// Package client — the embedded runtime for generated Go SDKs. Hand-authored\n// once and stitched into every generated client (see\n// scripts/generate-runtime-sources.mjs), semantically in lockstep with the\n// TypeScript runtime: auth OR-alternatives, a retry loop with Retry-After and\n// full-jitter backoff, per-attempt timeouts, idempotency keys, and middleware\n// hooks. Standard library only — a generated Go SDK has zero dependencies.\npackage client\n\nimport (\n\t"bytes"\n\t"context"\n\t"encoding/base64"\n\t"encoding/json"\n\t"errors"\n\t"fmt"\n\t"io"\n\t"math/rand"\n\t"net/http"\n\t"net/url"\n\t"strconv"\n\t"strings"\n\t"time"\n)\n\n// APIError is returned for a non-2xx response, carrying the decoded error body.\ntype APIError struct {\n\tURL string\n\tStatus int\n\tStatusText string\n\tBody any\n}\n\nfunc (e *APIError) Error() string {\n\treturn fmt.Sprintf("request failed with status %d", e.Status)\n}\n\n// TimeoutError is returned when a request attempt exceeds the configured\n// timeout — carrying the context a log line needs.\ntype TimeoutError struct {\n\tOperationID string\n\tTimeout time.Duration\n\tAttempt int\n}\n\nfunc (e *TimeoutError) Error() string {\n\treturn fmt.Sprintf("request %q timed out after %s (attempt %d)", e.OperationID, e.Timeout, e.Attempt)\n}\n\n// SecuritySpec mirrors the descriptor table\'s security entries.\ntype SecuritySpec struct {\n\tScheme string\n\tKind string // "bearer" | "basic" | "apiKey"\n\tName string // header/query/cookie name for apiKey\n\tIn string // "header" | "query" | "cookie"\n}\n\n// Auth holds the client credentials; zero value = anonymous.\ntype Auth struct {\n\tBearer func() string\n\tBasic *BasicAuth\n\tAPIKey map[string]func() string\n}\n\ntype BasicAuth struct {\n\tUsername string\n\tPassword string\n}\n\n// RetryConfig mirrors the TypeScript runtime\'s retry policy knobs.\ntype RetryConfig struct {\n\tRetries int\n\tRetryDelay time.Duration // base; default 1s\n\tRetryStrategy string // "" (exponential) | "fixed"\n\tNoJitter bool\n\t// RetryOn fully replaces the default predicate when set.\n\tRetryOn func(attempt int, resp *http.Response, err error) bool\n}\n\n// Middleware hooks run around every request (OnRequest before serialization order\n// is N/A in Go — bodies are values; OnResponse runs in reverse registration order).\ntype Middleware struct {\n\tOnRequest func(req *http.Request)\n\tOnResponse func(resp *http.Response)\n}\n\n// Config is the per-client configuration shared by every operation method.\ntype Config struct {\n\tServerURL string\n\tHTTPClient *http.Client\n\tHeaders map[string]string\n\tTimeout time.Duration\n\tRetry RetryConfig\n\tMiddleware []Middleware\n\tIdempotencyKey func() string\n\tAuth Auth\n}\n\nfunc resolveToken(provider func() string) string {\n\tif provider == nil {\n\t\treturn ""\n\t}\n\treturn provider()\n}\n\nfunc schemeConfigured(spec SecuritySpec, auth Auth) bool {\n\tswitch spec.Kind {\n\tcase "apiKey":\n\t\t_, ok := auth.APIKey[spec.Scheme]\n\t\treturn ok\n\tcase "bearer":\n\t\treturn auth.Bearer != nil\n\tdefault:\n\t\treturn auth.Basic != nil\n\t}\n}\n\n// resolveAuth applies the first fully-configured OR-alternative; when none is,\n// the first alternative\'s configured schemes are still sent (the server rejects\n// the request — same behavior as the TypeScript runtime).\nfunc resolveAuth(security [][]SecuritySpec, auth Auth) (map[string]string, url.Values) {\n\theaders := map[string]string{}\n\tquery := url.Values{}\n\tif len(security) == 0 {\n\t\treturn headers, query\n\t}\n\talternative := security[0]\n\tfor _, candidate := range security {\n\t\tall := true\n\t\tfor _, spec := range candidate {\n\t\t\tif !schemeConfigured(spec, auth) {\n\t\t\t\tall = false\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif all {\n\t\t\talternative = candidate\n\t\t\tbreak\n\t\t}\n\t}\n\tvar cookies []string\n\tfor _, spec := range alternative {\n\t\tswitch spec.Kind {\n\t\tcase "apiKey":\n\t\t\tprovider, ok := auth.APIKey[spec.Scheme]\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalue := resolveToken(provider)\n\t\t\tswitch spec.In {\n\t\t\tcase "query":\n\t\t\t\tquery.Set(spec.Name, value)\n\t\t\tcase "cookie":\n\t\t\t\tcookies = append(cookies, spec.Name+"="+url.QueryEscape(value))\n\t\t\tdefault:\n\t\t\t\theaders[spec.Name] = value\n\t\t\t}\n\t\tcase "bearer":\n\t\t\tif auth.Bearer != nil {\n\t\t\t\theaders["Authorization"] = "Bearer " + resolveToken(auth.Bearer)\n\t\t\t}\n\t\tdefault:\n\t\t\tif auth.Basic != nil {\n\t\t\t\ttoken := base64.StdEncoding.EncodeToString([]byte(auth.Basic.Username + ":" + auth.Basic.Password))\n\t\t\t\theaders["Authorization"] = "Basic " + token\n\t\t\t}\n\t\t}\n\t}\n\tif len(cookies) > 0 {\n\t\theaders["Cookie"] = strings.Join(cookies, "; ")\n\t}\n\treturn headers, query\n}\n\n// buildURL substitutes {param} path placeholders with percent-encoded values.\nfunc buildURL(serverURL, path string, pathParams map[string]string) string {\n\tfilled := path\n\tfor name, value := range pathParams {\n\t\tfilled = strings.ReplaceAll(filled, "{"+name+"}", url.PathEscape(value))\n\t}\n\treturn strings.TrimRight(serverURL, "/") + filled\n}\n\nvar transientStatus = map[int]bool{408: true, 429: true, 500: true, 502: true, 503: true, 504: true}\n\nfunc defaultRetryOn(method string, headers map[string]string, resp *http.Response, err error) bool {\n\tsafe := false\n\tswitch strings.ToUpper(method) {\n\tcase "GET", "HEAD", "PUT", "DELETE", "OPTIONS":\n\t\tsafe = true\n\t}\n\tif _, ok := headers["Idempotency-Key"]; ok {\n\t\tsafe = true\n\t}\n\tif !safe {\n\t\treturn false\n\t}\n\tif err != nil {\n\t\treturn true\n\t}\n\treturn resp != nil && transientStatus[resp.StatusCode]\n}\n\nfunc retryDelay(retry RetryConfig, attempt int, retryAfter string) time.Duration {\n\tif retryAfter != "" {\n\t\tif seconds, err := strconv.ParseFloat(retryAfter, 64); err == nil {\n\t\t\treturn time.Duration(seconds * float64(time.Second))\n\t\t}\n\t}\n\tbase := retry.RetryDelay\n\tif base == 0 {\n\t\tbase = time.Second\n\t}\n\traw := base\n\tif retry.RetryStrategy != "fixed" {\n\t\traw = base * time.Duration(1<<(attempt-1))\n\t}\n\tif retry.NoJitter {\n\t\treturn raw\n\t}\n\treturn time.Duration(rand.Int63n(int64(raw) + 1))\n}\n\ntype requestSpec struct {\n\tOperationID string\n\tMethod string\n\tURL string\n\tHeaders map[string]string\n\tQuery url.Values\n\tBody io.Reader\n\tContentType string\n\tTimeout time.Duration\n\tRetry *RetryConfig\n\tIdempotencyKey string\n\t// bodyBytes is retained so retries can replay the body.\n\tbodyBytes []byte\n}\n\n// send is the request core: header merge, idempotency keys, the retry loop\n// (fresh timeout budget per attempt), and the middleware onion.\nfunc send(ctx context.Context, config *Config, spec requestSpec) (*http.Response, error) {\n\tretry := config.Retry\n\tif spec.Retry != nil {\n\t\tretry = *spec.Retry\n\t}\n\ttimeout := config.Timeout\n\tif spec.Timeout != 0 {\n\t\ttimeout = spec.Timeout\n\t}\n\theaders := map[string]string{}\n\tfor key, value := range config.Headers {\n\t\theaders[key] = value\n\t}\n\tfor key, value := range spec.Headers {\n\t\theaders[key] = value\n\t}\n\tmethod := strings.ToUpper(spec.Method)\n\tif (method == "POST" || method == "PATCH") && headers["Idempotency-Key"] == "" {\n\t\tif spec.IdempotencyKey != "" {\n\t\t\theaders["Idempotency-Key"] = spec.IdempotencyKey\n\t\t} else if config.IdempotencyKey != nil {\n\t\t\theaders["Idempotency-Key"] = config.IdempotencyKey()\n\t\t}\n\t}\n\thttpClient := config.HTTPClient\n\tif httpClient == nil {\n\t\thttpClient = http.DefaultClient\n\t}\n\tif spec.Body != nil {\n\t\tpayload, err := io.ReadAll(spec.Body)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tspec.bodyBytes = payload\n\t}\n\tfullURL := spec.URL\n\tif len(spec.Query) > 0 {\n\t\tseparator := "?"\n\t\tif strings.Contains(fullURL, "?") {\n\t\t\tseparator = "&"\n\t\t}\n\t\tfullURL += separator + spec.Query.Encode()\n\t}\n\tmaxAttempts := 1 + retry.Retries\n\tfor attempt := 1; ; attempt++ {\n\t\tattemptCtx := ctx\n\t\tvar cancel context.CancelFunc\n\t\tif timeout > 0 {\n\t\t\tattemptCtx, cancel = context.WithTimeout(ctx, timeout)\n\t\t}\n\t\tvar bodyReader io.Reader\n\t\tif spec.bodyBytes != nil {\n\t\t\tbodyReader = bytes.NewReader(spec.bodyBytes)\n\t\t}\n\t\treq, err := http.NewRequestWithContext(attemptCtx, method, fullURL, bodyReader)\n\t\tif err != nil {\n\t\t\tif cancel != nil {\n\t\t\t\tcancel()\n\t\t\t}\n\t\t\treturn nil, err\n\t\t}\n\t\tfor key, value := range headers {\n\t\t\treq.Header.Set(key, value)\n\t\t}\n\t\tif spec.ContentType != "" && spec.bodyBytes != nil {\n\t\t\treq.Header.Set("Content-Type", spec.ContentType)\n\t\t}\n\t\tfor _, mw := range config.Middleware {\n\t\t\tif mw.OnRequest != nil {\n\t\t\t\tmw.OnRequest(req)\n\t\t\t}\n\t\t}\n\t\tresp, err := httpClient.Do(req)\n\t\tshouldRetry := retry.RetryOn\n\t\tretryable := false\n\t\tif shouldRetry != nil {\n\t\t\tretryable = shouldRetry(attempt, resp, err)\n\t\t} else {\n\t\t\tretryable = defaultRetryOn(method, headers, resp, err)\n\t\t}\n\t\tif err != nil {\n\t\t\tif cancel != nil {\n\t\t\t\tcancel()\n\t\t\t}\n\t\t\ttimedOut := errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil\n\t\t\tif attempt < maxAttempts && retryable {\n\t\t\t\ttime.Sleep(retryDelay(retry, attempt, ""))\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif timedOut {\n\t\t\t\treturn nil, &TimeoutError{OperationID: spec.OperationID, Timeout: timeout, Attempt: attempt}\n\t\t\t}\n\t\t\treturn nil, err\n\t\t}\n\t\tfor i := len(config.Middleware) - 1; i >= 0; i-- {\n\t\t\tif config.Middleware[i].OnResponse != nil {\n\t\t\t\tconfig.Middleware[i].OnResponse(resp)\n\t\t\t}\n\t\t}\n\t\tif resp.StatusCode >= 400 && attempt < maxAttempts && retryable {\n\t\t\tafter := resp.Header.Get("Retry-After")\n\t\t\tio.Copy(io.Discard, resp.Body)\n\t\t\tresp.Body.Close()\n\t\t\tif cancel != nil {\n\t\t\t\tcancel()\n\t\t\t}\n\t\t\ttime.Sleep(retryDelay(retry, attempt, after))\n\t\t\tcontinue\n\t\t}\n\t\t// The response body outlives this call; tie the attempt context\'s lifetime to it.\n\t\tif cancel != nil {\n\t\t\tresp.Body = &cancelOnClose{ReadCloser: resp.Body, cancel: cancel}\n\t\t}\n\t\treturn resp, nil\n\t}\n}\n\ntype cancelOnClose struct {\n\tio.ReadCloser\n\tcancel context.CancelFunc\n}\n\nfunc (c *cancelOnClose) Close() error {\n\tc.cancel()\n\treturn c.ReadCloser.Close()\n}\n\n// decodeJSON decodes a response body into target; a nil target drains and closes.\nfunc decodeJSON(resp *http.Response, target any) error {\n\tdefer resp.Body.Close()\n\tif target == nil {\n\t\t_, err := io.Copy(io.Discard, resp.Body)\n\t\treturn err\n\t}\n\treturn json.NewDecoder(resp.Body).Decode(target)\n}\n\n// apiErrorFrom builds the structured error for a non-2xx response.\nfunc apiErrorFrom(resp *http.Response, requestURL string) error {\n\tdefer resp.Body.Close()\n\tvar body any\n\tdata, _ := io.ReadAll(resp.Body)\n\tif len(data) > 0 {\n\t\tif err := json.Unmarshal(data, &body); err != nil {\n\t\t\tbody = string(data)\n\t\t}\n\t}\n\treturn &APIError{URL: requestURL, Status: resp.StatusCode, StatusText: resp.Status, Body: body}\n}\n'; + '// Package client — the embedded runtime for generated Go SDKs. Hand-authored\n// once and stitched into every generated client (see\n// scripts/generate-runtime-sources.mjs), semantically in lockstep with the\n// TypeScript runtime: auth OR-alternatives, a retry loop with Retry-After and\n// full-jitter backoff, per-attempt timeouts, idempotency keys, and middleware\n// hooks. Standard library only — a generated Go SDK has zero dependencies.\npackage client\n\nimport (\n\t"bytes"\n\t"context"\n\t"encoding/base64"\n\t"encoding/json"\n\t"errors"\n\t"fmt"\n\t"io"\n\t"math/rand"\n\t"mime/multipart"\n\t"net/http"\n\t"net/url"\n\t"strconv"\n\t"strings"\n\t"time"\n)\n\n// APIError is returned for a non-2xx response, carrying the decoded error body.\ntype APIError struct {\n\tURL string\n\tStatus int\n\tStatusText string\n\tBody any\n}\n\nfunc (e *APIError) Error() string {\n\treturn fmt.Sprintf("request failed with status %d", e.Status)\n}\n\n// TimeoutError is returned when a request attempt exceeds the configured\n// timeout — carrying the context a log line needs.\ntype TimeoutError struct {\n\tOperationID string\n\tTimeout time.Duration\n\tAttempt int\n}\n\nfunc (e *TimeoutError) Error() string {\n\treturn fmt.Sprintf("request %q timed out after %s (attempt %d)", e.OperationID, e.Timeout, e.Attempt)\n}\n\n// SecuritySpec mirrors the descriptor table\'s security entries.\ntype SecuritySpec struct {\n\tScheme string\n\tKind string // "bearer" | "basic" | "apiKey"\n\tName string // header/query/cookie name for apiKey\n\tIn string // "header" | "query" | "cookie"\n}\n\n// Auth holds the client credentials; zero value = anonymous.\ntype Auth struct {\n\tBearer func() string\n\tBasic *BasicAuth\n\tAPIKey map[string]func() string\n}\n\ntype BasicAuth struct {\n\tUsername string\n\tPassword string\n}\n\n// RetryConfig mirrors the TypeScript runtime\'s retry policy knobs.\ntype RetryConfig struct {\n\tRetries int\n\tRetryDelay time.Duration // base; default 1s\n\tRetryStrategy string // "" (exponential) | "fixed"\n\tNoJitter bool\n\t// RetryOn fully replaces the default predicate when set.\n\tRetryOn func(attempt int, resp *http.Response, err error) bool\n}\n\n// Middleware hooks run around every request (OnRequest before serialization order\n// is N/A in Go — bodies are values; OnResponse runs in reverse registration order).\ntype Middleware struct {\n\tOnRequest func(req *http.Request)\n\tOnResponse func(resp *http.Response)\n}\n\n// Config is the per-client configuration shared by every operation method.\ntype Config struct {\n\tServerURL string\n\tHTTPClient *http.Client\n\tHeaders map[string]string\n\tTimeout time.Duration\n\tRetry RetryConfig\n\tMiddleware []Middleware\n\tIdempotencyKey func() string\n\tAuth Auth\n}\n\nfunc resolveToken(provider func() string) string {\n\tif provider == nil {\n\t\treturn ""\n\t}\n\treturn provider()\n}\n\nfunc schemeConfigured(spec SecuritySpec, auth Auth) bool {\n\tswitch spec.Kind {\n\tcase "apiKey":\n\t\t_, ok := auth.APIKey[spec.Scheme]\n\t\treturn ok\n\tcase "bearer":\n\t\treturn auth.Bearer != nil\n\tdefault:\n\t\treturn auth.Basic != nil\n\t}\n}\n\n// resolveAuth applies the first fully-configured OR-alternative; when none is,\n// the first alternative\'s configured schemes are still sent (the server rejects\n// the request — same behavior as the TypeScript runtime).\nfunc resolveAuth(security [][]SecuritySpec, auth Auth) (map[string]string, url.Values) {\n\theaders := map[string]string{}\n\tquery := url.Values{}\n\tif len(security) == 0 {\n\t\treturn headers, query\n\t}\n\talternative := security[0]\n\tfor _, candidate := range security {\n\t\tall := true\n\t\tfor _, spec := range candidate {\n\t\t\tif !schemeConfigured(spec, auth) {\n\t\t\t\tall = false\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif all {\n\t\t\talternative = candidate\n\t\t\tbreak\n\t\t}\n\t}\n\tvar cookies []string\n\tfor _, spec := range alternative {\n\t\tswitch spec.Kind {\n\t\tcase "apiKey":\n\t\t\tprovider, ok := auth.APIKey[spec.Scheme]\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalue := resolveToken(provider)\n\t\t\tswitch spec.In {\n\t\t\tcase "query":\n\t\t\t\tquery.Set(spec.Name, value)\n\t\t\tcase "cookie":\n\t\t\t\tcookies = append(cookies, spec.Name+"="+url.QueryEscape(value))\n\t\t\tdefault:\n\t\t\t\theaders[spec.Name] = value\n\t\t\t}\n\t\tcase "bearer":\n\t\t\tif auth.Bearer != nil {\n\t\t\t\theaders["Authorization"] = "Bearer " + resolveToken(auth.Bearer)\n\t\t\t}\n\t\tdefault:\n\t\t\tif auth.Basic != nil {\n\t\t\t\ttoken := base64.StdEncoding.EncodeToString([]byte(auth.Basic.Username + ":" + auth.Basic.Password))\n\t\t\t\theaders["Authorization"] = "Basic " + token\n\t\t\t}\n\t\t}\n\t}\n\tif len(cookies) > 0 {\n\t\theaders["Cookie"] = strings.Join(cookies, "; ")\n\t}\n\treturn headers, query\n}\n\n// buildURL substitutes {param} path placeholders with percent-encoded values.\nfunc buildURL(serverURL, path string, pathParams map[string]string) string {\n\tfilled := path\n\tfor name, value := range pathParams {\n\t\tfilled = strings.ReplaceAll(filled, "{"+name+"}", url.PathEscape(value))\n\t}\n\treturn strings.TrimRight(serverURL, "/") + filled\n}\n\nvar transientStatus = map[int]bool{408: true, 429: true, 500: true, 502: true, 503: true, 504: true}\n\nfunc defaultRetryOn(method string, headers map[string]string, resp *http.Response, err error) bool {\n\tsafe := false\n\tswitch strings.ToUpper(method) {\n\tcase "GET", "HEAD", "PUT", "DELETE", "OPTIONS":\n\t\tsafe = true\n\t}\n\tif _, ok := headers["Idempotency-Key"]; ok {\n\t\tsafe = true\n\t}\n\tif !safe {\n\t\treturn false\n\t}\n\tif err != nil {\n\t\treturn true\n\t}\n\treturn resp != nil && transientStatus[resp.StatusCode]\n}\n\nfunc retryDelay(retry RetryConfig, attempt int, retryAfter string) time.Duration {\n\tif retryAfter != "" {\n\t\tif seconds, err := strconv.ParseFloat(retryAfter, 64); err == nil {\n\t\t\treturn time.Duration(seconds * float64(time.Second))\n\t\t}\n\t}\n\tbase := retry.RetryDelay\n\tif base == 0 {\n\t\tbase = time.Second\n\t}\n\traw := base\n\tif retry.RetryStrategy != "fixed" {\n\t\traw = base * time.Duration(1<<(attempt-1))\n\t}\n\tif retry.NoJitter {\n\t\treturn raw\n\t}\n\treturn time.Duration(rand.Int63n(int64(raw) + 1))\n}\n\ntype requestSpec struct {\n\tOperationID string\n\tMethod string\n\tURL string\n\tHeaders map[string]string\n\tQuery url.Values\n\tBody io.Reader\n\tContentType string\n\tTimeout time.Duration\n\tRetry *RetryConfig\n\tIdempotencyKey string\n\t// bodyBytes is retained so retries can replay the body.\n\tbodyBytes []byte\n}\n\n// send is the request core: header merge, idempotency keys, the retry loop\n// (fresh timeout budget per attempt), and the middleware onion.\nfunc send(ctx context.Context, config *Config, spec requestSpec) (*http.Response, error) {\n\tretry := config.Retry\n\tif spec.Retry != nil {\n\t\tretry = *spec.Retry\n\t}\n\ttimeout := config.Timeout\n\tif spec.Timeout != 0 {\n\t\ttimeout = spec.Timeout\n\t}\n\theaders := map[string]string{}\n\tfor key, value := range config.Headers {\n\t\theaders[key] = value\n\t}\n\tfor key, value := range spec.Headers {\n\t\theaders[key] = value\n\t}\n\tmethod := strings.ToUpper(spec.Method)\n\tif (method == "POST" || method == "PATCH") && headers["Idempotency-Key"] == "" {\n\t\tif spec.IdempotencyKey != "" {\n\t\t\theaders["Idempotency-Key"] = spec.IdempotencyKey\n\t\t} else if config.IdempotencyKey != nil {\n\t\t\theaders["Idempotency-Key"] = config.IdempotencyKey()\n\t\t}\n\t}\n\thttpClient := config.HTTPClient\n\tif httpClient == nil {\n\t\thttpClient = http.DefaultClient\n\t}\n\tif spec.Body != nil {\n\t\tpayload, err := io.ReadAll(spec.Body)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tspec.bodyBytes = payload\n\t}\n\tfullURL := spec.URL\n\tif len(spec.Query) > 0 {\n\t\tseparator := "?"\n\t\tif strings.Contains(fullURL, "?") {\n\t\t\tseparator = "&"\n\t\t}\n\t\tfullURL += separator + spec.Query.Encode()\n\t}\n\tmaxAttempts := 1 + retry.Retries\n\tfor attempt := 1; ; attempt++ {\n\t\tattemptCtx := ctx\n\t\tvar cancel context.CancelFunc\n\t\tif timeout > 0 {\n\t\t\tattemptCtx, cancel = context.WithTimeout(ctx, timeout)\n\t\t}\n\t\tvar bodyReader io.Reader\n\t\tif spec.bodyBytes != nil {\n\t\t\tbodyReader = bytes.NewReader(spec.bodyBytes)\n\t\t}\n\t\treq, err := http.NewRequestWithContext(attemptCtx, method, fullURL, bodyReader)\n\t\tif err != nil {\n\t\t\tif cancel != nil {\n\t\t\t\tcancel()\n\t\t\t}\n\t\t\treturn nil, err\n\t\t}\n\t\tfor key, value := range headers {\n\t\t\treq.Header.Set(key, value)\n\t\t}\n\t\tif spec.ContentType != "" && spec.bodyBytes != nil {\n\t\t\treq.Header.Set("Content-Type", spec.ContentType)\n\t\t}\n\t\tfor _, mw := range config.Middleware {\n\t\t\tif mw.OnRequest != nil {\n\t\t\t\tmw.OnRequest(req)\n\t\t\t}\n\t\t}\n\t\tresp, err := httpClient.Do(req)\n\t\tshouldRetry := retry.RetryOn\n\t\tretryable := false\n\t\tif shouldRetry != nil {\n\t\t\tretryable = shouldRetry(attempt, resp, err)\n\t\t} else {\n\t\t\tretryable = defaultRetryOn(method, headers, resp, err)\n\t\t}\n\t\tif err != nil {\n\t\t\tif cancel != nil {\n\t\t\t\tcancel()\n\t\t\t}\n\t\t\ttimedOut := errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil\n\t\t\tif attempt < maxAttempts && retryable {\n\t\t\t\ttime.Sleep(retryDelay(retry, attempt, ""))\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif timedOut {\n\t\t\t\treturn nil, &TimeoutError{OperationID: spec.OperationID, Timeout: timeout, Attempt: attempt}\n\t\t\t}\n\t\t\treturn nil, err\n\t\t}\n\t\tfor i := len(config.Middleware) - 1; i >= 0; i-- {\n\t\t\tif config.Middleware[i].OnResponse != nil {\n\t\t\t\tconfig.Middleware[i].OnResponse(resp)\n\t\t\t}\n\t\t}\n\t\tif resp.StatusCode >= 400 && attempt < maxAttempts && retryable {\n\t\t\tafter := resp.Header.Get("Retry-After")\n\t\t\tio.Copy(io.Discard, resp.Body)\n\t\t\tresp.Body.Close()\n\t\t\tif cancel != nil {\n\t\t\t\tcancel()\n\t\t\t}\n\t\t\ttime.Sleep(retryDelay(retry, attempt, after))\n\t\t\tcontinue\n\t\t}\n\t\t// The response body outlives this call; tie the attempt context\'s lifetime to it.\n\t\tif cancel != nil {\n\t\t\tresp.Body = &cancelOnClose{ReadCloser: resp.Body, cancel: cancel}\n\t\t}\n\t\treturn resp, nil\n\t}\n}\n\ntype cancelOnClose struct {\n\tio.ReadCloser\n\tcancel context.CancelFunc\n}\n\nfunc (c *cancelOnClose) Close() error {\n\tc.cancel()\n\treturn c.ReadCloser.Close()\n}\n\n// decodeJSON decodes a response body into target; a nil target drains and closes.\nfunc decodeJSON(resp *http.Response, target any) error {\n\tdefer resp.Body.Close()\n\tif target == nil {\n\t\t_, err := io.Copy(io.Discard, resp.Body)\n\t\treturn err\n\t}\n\treturn json.NewDecoder(resp.Body).Decode(target)\n}\n\n// apiErrorFrom builds the structured error for a non-2xx response.\nfunc apiErrorFrom(resp *http.Response, requestURL string) error {\n\tdefer resp.Body.Close()\n\tvar body any\n\tdata, _ := io.ReadAll(resp.Body)\n\tif len(data) > 0 {\n\t\tif err := json.Unmarshal(data, &body); err != nil {\n\t\t\tbody = string(data)\n\t\t}\n\t}\n\treturn &APIError{URL: requestURL, Status: resp.StatusCode, StatusText: resp.Status, Body: body}\n}\n\n// ─── Pagination ───\n\n// PaginationSpec mirrors the descriptor table\'s pagination entries.\ntype PaginationSpec struct {\n\tStyle string\n\tParam string\n\tNextCursor string\n\tHasMore string\n\tLimitParam string\n\tItems string\n}\n\n// resolvePointer walks an RFC 6901 JSON pointer over decoded JSON; nil on any miss.\nfunc resolvePointer(data any, pointer string) any {\n\tif pointer == "" {\n\t\treturn data\n\t}\n\tif !strings.HasPrefix(pointer, "/") {\n\t\treturn nil\n\t}\n\tcurrent := data\n\tfor _, token := range strings.Split(pointer[1:], "/") {\n\t\tkey := strings.ReplaceAll(strings.ReplaceAll(token, "~1", "/"), "~0", "~")\n\t\tswitch typed := current.(type) {\n\t\tcase map[string]any:\n\t\t\tcurrent = typed[key]\n\t\tcase []any:\n\t\t\tindex, err := strconv.Atoi(key)\n\t\t\tif err != nil || index < 0 || index >= len(typed) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tcurrent = typed[index]\n\t\tdefault:\n\t\t\treturn nil\n\t\t}\n\t\tif current == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn current\n}\n\n// reencode converts decoded JSON (maps/slices) into a typed value via a JSON round-trip.\nfunc reencode(raw any, target any) error {\n\tdata, err := json.Marshal(raw)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn json.Unmarshal(data, target)\n}\n\ntype pageCall func(params url.Values) (any, *http.Response, error)\n\n// iterPages yields raw page JSON per the pagination spec — the same stop\n// conditions and infinite-loop guards as the TypeScript runtime. The returned\n// function is a range-over-func iterator (Go 1.23+) and plainly callable before that.\nfunc iterPages(call pageCall, spec PaginationSpec, base url.Values) func(yield func(any, error) bool) {\n\treturn func(yield func(any, error) bool) {\n\t\tswitch spec.Style {\n\t\tcase "cursor":\n\t\t\tvar cursor any\n\t\t\tif values, ok := base[spec.Param]; ok && len(values) > 0 {\n\t\t\t\tcursor = values[0]\n\t\t\t}\n\t\t\tfor {\n\t\t\t\tparams := cloneValues(base)\n\t\t\t\tif cursor != nil {\n\t\t\t\t\tparams.Set(spec.Param, fmt.Sprint(cursor))\n\t\t\t\t}\n\t\t\t\tpage, _, err := call(params)\n\t\t\t\tif err != nil {\n\t\t\t\t\tyield(nil, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif !yield(page, nil) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif spec.HasMore != "" {\n\t\t\t\t\tif more, ok := resolvePointer(page, spec.HasMore).(bool); ok && !more {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tnext := resolvePointer(page, spec.NextCursor)\n\t\t\t\tif next == nil || next == "" {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tswitch next.(type) {\n\t\t\t\tcase string, float64:\n\t\t\t\tdefault:\n\t\t\t\t\tyield(nil, fmt.Errorf("pagination cursor at %s is not a string or number", spec.NextCursor))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif cursor != nil && fmt.Sprint(next) == fmt.Sprint(cursor) {\n\t\t\t\t\tyield(nil, errors.New("pagination did not advance: the operation returned the same cursor twice"))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tcursor = next\n\t\t\t}\n\t\tcase "link":\n\t\t\tparams := cloneValues(base)\n\t\t\tprevious := ""\n\t\t\tfor {\n\t\t\t\tpage, resp, err := call(params)\n\t\t\t\tif err != nil {\n\t\t\t\t\tyield(nil, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif !yield(page, nil) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\ttarget := linkNext(resp.Header.Get("Link"))\n\t\t\t\tif target == "" {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tpageURL := ""\n\t\t\t\tif resp.Request != nil && resp.Request.URL != nil {\n\t\t\t\t\tpageURL = resp.Request.URL.String()\n\t\t\t\t}\n\t\t\t\tbaseURL, err := url.Parse(pageURL)\n\t\t\t\tif err != nil || pageURL == "" {\n\t\t\t\t\tbaseURL, _ = url.Parse("http://relative.invalid")\n\t\t\t\t}\n\t\t\t\ttargetURL, err := baseURL.Parse(target)\n\t\t\t\tif err != nil {\n\t\t\t\t\tyield(nil, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tnext := targetURL.String()\n\t\t\t\tif next == previous || next == pageURL {\n\t\t\t\t\tyield(nil, errors.New(`pagination did not advance: the Link rel="next" target repeats`))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tprevious = next\n\t\t\t\tparams = cloneValues(base)\n\t\t\t\tfor key, values := range targetURL.Query() {\n\t\t\t\t\tfor _, value := range values {\n\t\t\t\t\t\tparams.Add(key, value)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\tdefault: // offset / page\n\t\t\tposition := 0\n\t\t\tif spec.Style == "page" {\n\t\t\t\tposition = 1\n\t\t\t}\n\t\t\tif values, ok := base[spec.Param]; ok && len(values) > 0 && values[0] != "" {\n\t\t\t\tif parsed, err := strconv.Atoi(values[0]); err == nil {\n\t\t\t\t\tposition = parsed\n\t\t\t\t}\n\t\t\t}\n\t\t\tpreviousItems := ""\n\t\t\tfor {\n\t\t\t\tparams := cloneValues(base)\n\t\t\t\tparams.Set(spec.Param, strconv.Itoa(position))\n\t\t\t\tpage, _, err := call(params)\n\t\t\t\tif err != nil {\n\t\t\t\t\tyield(nil, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\titems, _ := resolvePointer(page, spec.Items).([]any)\n\t\t\t\tserialized := ""\n\t\t\t\tif items != nil {\n\t\t\t\t\tserialized = fmt.Sprint(items)\n\t\t\t\t\tif serialized == previousItems {\n\t\t\t\t\t\tyield(nil, errors.New("pagination did not advance: the operation returned the same page twice"))\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif !yield(page, nil) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif len(items) == 0 {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tpreviousItems = serialized\n\t\t\t\tif spec.Style == "page" {\n\t\t\t\t\tposition++\n\t\t\t\t} else {\n\t\t\t\t\tposition += len(items)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc cloneValues(values url.Values) url.Values {\n\tout := url.Values{}\n\tfor key, entries := range values {\n\t\tfor _, entry := range entries {\n\t\t\tout.Add(key, entry)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc linkNext(header string) string {\n\tif header == "" {\n\t\treturn ""\n\t}\n\tfor _, entry := range strings.Split(header, ",") {\n\t\tparts := strings.Split(entry, ";")\n\t\tif len(parts) < 2 {\n\t\t\tcontinue\n\t\t}\n\t\ttarget := strings.TrimSpace(parts[0])\n\t\tif !strings.HasPrefix(target, "<") || !strings.HasSuffix(target, ">") {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, param := range parts[1:] {\n\t\t\ttrimmed := strings.TrimSpace(param)\n\t\t\tif strings.HasPrefix(trimmed, "rel=") {\n\t\t\t\trel := strings.Trim(strings.TrimPrefix(trimmed, "rel="), `"`)\n\t\t\t\tfor _, kind := range strings.Fields(rel) {\n\t\t\t\t\tif kind == "next" {\n\t\t\t\t\t\treturn strings.Trim(target, "<>")\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn ""\n}\n\n// ─── Server-Sent Events ───\n\n// ServerSentEvent is one decoded event; Data is the raw text (or parsed JSON\n// for operations that declare a JSON event stream).\ntype ServerSentEvent struct {\n\tEvent string\n\tData any\n\tID string\n\tRetry int\n}\n\nfunc parseSSEFrame(raw string, jsonData bool) (ServerSentEvent, bool, error) {\n\tevent := ServerSentEvent{Retry: -1}\n\tsawField := false\n\tvar dataLines []string\n\tnormalized := strings.ReplaceAll(strings.ReplaceAll(raw, "\\r\\n", "\\n"), "\\r", "\\n")\n\tfor _, line := range strings.Split(normalized, "\\n") {\n\t\tif line == "" || strings.HasPrefix(line, ":") {\n\t\t\tcontinue\n\t\t}\n\t\tfield, value, _ := strings.Cut(line, ":")\n\t\tvalue = strings.TrimPrefix(value, " ")\n\t\tsawField = true\n\t\tswitch field {\n\t\tcase "event":\n\t\t\tevent.Event = value\n\t\tcase "data":\n\t\t\tdataLines = append(dataLines, value)\n\t\tcase "id":\n\t\t\tevent.ID = value\n\t\tcase "retry":\n\t\t\tif parsed, err := strconv.Atoi(value); err == nil && parsed >= 0 && value != "" {\n\t\t\t\tevent.Retry = parsed\n\t\t\t}\n\t\t}\n\t}\n\tif !sawField {\n\t\treturn event, false, nil\n\t}\n\ttext := strings.Join(dataLines, "\\n")\n\tevent.Data = text\n\tif jsonData && text != "" {\n\t\tvar parsed any\n\t\tif err := json.Unmarshal([]byte(text), &parsed); err != nil {\n\t\t\treturn event, false, err\n\t\t}\n\t\tevent.Data = parsed\n\t}\n\treturn event, true, nil\n}\n\n// iterSSE streams events, reconnecting on dropped connections with Last-Event-ID\n// (a fresh open call = fresh auth); a 4xx/5xx or a bad JSON payload is definitive.\nfunc iterSSE(open func(extraHeaders map[string]string) (*http.Response, error), jsonData bool) func(yield func(ServerSentEvent, error) bool) {\n\treturn func(yield func(ServerSentEvent, error) bool) {\n\t\tlastEventID := ""\n\t\tserverRetry := -1\n\t\tfailures := 0\n\t\tfor {\n\t\t\theaders := map[string]string{"Accept": "text/event-stream"}\n\t\t\tif lastEventID != "" {\n\t\t\t\theaders["Last-Event-ID"] = lastEventID\n\t\t\t}\n\t\t\tresp, err := open(headers)\n\t\t\tif err == nil && resp.StatusCode >= 400 {\n\t\t\t\tyield(ServerSentEvent{}, apiErrorFrom(resp, ""))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err == nil {\n\t\t\t\tfailures = 0\n\t\t\t\tbuffer := ""\n\t\t\t\tchunk := make([]byte, 4096)\n\t\t\t\tclean := false\n\t\t\t\tfor {\n\t\t\t\t\tn, readErr := resp.Body.Read(chunk)\n\t\t\t\t\tbuffer += string(chunk[:n])\n\t\t\t\t\tfor {\n\t\t\t\t\t\tframe, rest, found := strings.Cut(buffer, "\\n\\n")\n\t\t\t\t\t\tif !found {\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t\tbuffer = rest\n\t\t\t\t\t\tevent, ok, parseErr := parseSSEFrame(frame, jsonData)\n\t\t\t\t\t\tif parseErr != nil {\n\t\t\t\t\t\t\tresp.Body.Close()\n\t\t\t\t\t\t\tyield(ServerSentEvent{}, parseErr)\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif ok {\n\t\t\t\t\t\t\tif event.ID != "" {\n\t\t\t\t\t\t\t\tlastEventID = event.ID\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif event.Retry >= 0 {\n\t\t\t\t\t\t\t\tserverRetry = event.Retry\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif !yield(event, nil) {\n\t\t\t\t\t\t\t\tresp.Body.Close()\n\t\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif readErr == io.EOF {\n\t\t\t\t\t\tclean = true\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\tif readErr != nil {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tresp.Body.Close()\n\t\t\t\tif clean {\n\t\t\t\t\tif strings.TrimSpace(buffer) != "" {\n\t\t\t\t\t\tif event, ok, parseErr := parseSSEFrame(buffer, jsonData); parseErr == nil && ok {\n\t\t\t\t\t\t\tyield(event, nil)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tfailures++\n\t\t\tbase := time.Second\n\t\t\tif serverRetry >= 0 {\n\t\t\t\tbase = time.Duration(serverRetry) * time.Millisecond\n\t\t\t}\n\t\t\tdelay := base * time.Duration(1<<(failures-1))\n\t\t\tif delay > 30*time.Second {\n\t\t\t\tdelay = 30 * time.Second\n\t\t\t}\n\t\t\ttime.Sleep(time.Duration(rand.Int63n(int64(delay) + 1)))\n\t\t}\n\t}\n}\n\n// ─── Multipart ───\n\n// toMultipart splits a typed body into a multipart/form-data payload: []byte\n// values upload as file parts, everything else as form fields (nested values\n// JSON-encoded) — mirroring the TypeScript runtime\'s FormData serialization.\nfunc toMultipart(body any) (string, io.Reader, error) {\n\tvar wire map[string]any\n\tif err := reencode(body, &wire); err != nil {\n\t\treturn "", nil, err\n\t}\n\tbuffer := &bytes.Buffer{}\n\twriter := multipart.NewWriter(buffer)\n\tfor key, value := range wire {\n\t\tswitch typed := value.(type) {\n\t\tcase string:\n\t\t\tif err := writer.WriteField(key, typed); err != nil {\n\t\t\t\treturn "", nil, err\n\t\t\t}\n\t\tcase float64, bool:\n\t\t\tif err := writer.WriteField(key, fmt.Sprint(typed)); err != nil {\n\t\t\t\treturn "", nil, err\n\t\t\t}\n\t\tdefault:\n\t\t\tencoded, err := json.Marshal(typed)\n\t\t\tif err != nil {\n\t\t\t\treturn "", nil, err\n\t\t\t}\n\t\t\tif err := writer.WriteField(key, string(encoded)); err != nil {\n\t\t\t\treturn "", nil, err\n\t\t\t}\n\t\t}\n\t}\n\tif err := writer.Close(); err != nil {\n\t\treturn "", nil, err\n\t}\n\treturn writer.FormDataContentType(), buffer, nil\n}\n'; diff --git a/packages/client-generator/src/generators/__tests__/go.test.ts b/packages/client-generator/src/generators/__tests__/go.test.ts index 9f55012d48..72cb766c59 100644 --- a/packages/client-generator/src/generators/__tests__/go.test.ts +++ b/packages/client-generator/src/generators/__tests__/go.test.ts @@ -173,6 +173,44 @@ const CAFE: ApiModel = { ], errorResponses: [], }, + { + name: 'streamEvents', + specName: 'streamEvents', + method: 'get', + path: '/events', + tags: ['Orders'], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { + status: '200', + contentType: 'text/event-stream', + schema: { kind: 'object', properties: [] }, + }, + ], + errorResponses: [], + }, + { + name: 'uploadPhoto', + specName: 'uploadPhoto', + method: 'post', + path: '/photos', + tags: ['Orders'], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + requestBody: { + contentType: 'multipart/form-data', + schema: { kind: 'object', properties: [] }, + }, + successResponses: [{ status: '204', contentType: '', schema: { kind: 'unknown' } }], + errorResponses: [], + }, { name: 'getOrder', specName: 'getOrder', @@ -279,3 +317,27 @@ describe('goGenerator (full client assembly)', () => { expectGoCompiles(out); }); }); + +describe('goGenerator parity features', () => { + it('paginated operations gain Pages/Items yield-func iterators with typed elements', () => { + const out = generateGo(); + expect(out).toContain('Pagination: &PaginationSpec{Style: "cursor", Param: "after"'); + expect(out).toContain( + 'func (c *Client) ListOrdersPages(ctx context.Context, params *ListOrdersParams) func(yield func(OrderPage, error) bool) {' + ); + expect(out).toContain( + 'func (c *Client) ListOrdersItems(ctx context.Context, params *ListOrdersParams) func(yield func(Order, error) bool) {' + ); + expect(out).toContain('iterPages(call, *op.Pagination, base)'); + }); + + it('SSE operations stream events; multipart bodies route through toMultipart', () => { + const out = generateGo(); + expect(out).toContain( + 'func (c *Client) StreamEvents(ctx context.Context) func(yield func(ServerSentEvent, error) bool) {' + ); + expect(out).toContain('return iterSSE(open,'); + expect(out).toContain('contentType, reader, err := toMultipart(body)'); + expectGoCompiles(out); + }); +}); diff --git a/packages/client-generator/src/generators/go.ts b/packages/client-generator/src/generators/go.ts index 4f4b558977..66958e39c4 100644 --- a/packages/client-generator/src/generators/go.ts +++ b/packages/client-generator/src/generators/go.ts @@ -13,8 +13,11 @@ import { flattenAllOf, identifierFor, isNullable, + paginationRuleFor, RESERVED_WORDS, + schemaAtPointer, unwrapNullable, + type NeutralPaginationRule, } from '../authoring/index.js'; import { GO_RUNTIME_SOURCE } from '../emitters/go-runtime-sources.js'; import type { @@ -295,6 +298,30 @@ function stripHeader(source: string): string { return out.join('\n').trim(); } +/** The op's SSE success response, when it streams text/event-stream. */ +function sseResponse(op: OperationModel) { + return op.successResponses.find((response) => + response.contentType.toLowerCase().includes('text/event-stream') + ); +} + +function isMultipart(op: OperationModel): boolean { + return op.requestBody?.contentType.toLowerCase().includes('multipart') ?? false; +} + +/** The neutral rule as a `&PaginationSpec{…}` composite literal for the operations table. */ +function goPaginationLiteral(rule: NeutralPaginationRule): string { + const fields = [ + `Style: ${JSON.stringify(rule.style)}`, + ...(rule.param !== undefined ? [`Param: ${JSON.stringify(rule.param)}`] : []), + ...(rule.nextCursor !== undefined ? [`NextCursor: ${JSON.stringify(rule.nextCursor)}`] : []), + ...(rule.hasMore !== undefined ? [`HasMore: ${JSON.stringify(rule.hasMore)}`] : []), + ...(rule.limitParam !== undefined ? [`LimitParam: ${JSON.stringify(rule.limitParam)}`] : []), + ...(rule.items !== undefined ? [`Items: ${JSON.stringify(rule.items)}`] : []), + ]; + return `&PaginationSpec{${fields.join(', ')}}`; +} + function writeGoMethod(writer: CodeWriter, op: OperationModel, ident: string): void { const pathArgs = op.pathParams.map((param) => ({ param, @@ -310,14 +337,20 @@ function writeGoMethod(writer: CodeWriter, op: OperationModel, ident: string): v ...(op.requestBody ? [`body ${goType(op.requestBody.schema)}`] : []), ...(hasParams ? [`params *${ident}Params`] : []), ]; - const returns = returnType === undefined ? 'error' : `(${returnType}, error)`; + const sse = sseResponse(op); + const returns = + sse !== undefined + ? 'func(yield func(ServerSentEvent, error) bool)' + : returnType === undefined + ? 'error' + : `(${returnType}, error)`; const fail = (errExpr: string) => returnType === undefined ? `return ${errExpr}` : `return out, ${errExpr}`; writeDocComment(writer, ident, op.summary); writer.block( `func (c *Client) ${ident}(${args.join(', ')}) ${returns} {`, () => { - if (returnType !== undefined) writer.line(`var out ${returnType}`); + if (sse === undefined && returnType !== undefined) writer.line(`var out ${returnType}`); writer.line(`op := operations[${JSON.stringify(op.specName ?? op.name)}]`); writer.line('authHeaders, query := resolveAuth(op.Security, c.config.Auth)'); if (hasParams) { @@ -346,6 +379,36 @@ function writeGoMethod(writer: CodeWriter, op: OperationModel, ident: string): v writer.line( `requestURL := buildURL(c.config.ServerURL, op.Path, map[string]string{${pathDict}})` ); + if (sse !== undefined) { + writer.block( + 'open := func(extraHeaders map[string]string) (*http.Response, error) {', + () => { + writer.line('merged := map[string]string{}'); + writer.block( + 'for key, value := range authHeaders {', + () => { + writer.line('merged[key] = value'); + }, + '}' + ); + writer.block( + 'for key, value := range extraHeaders {', + () => { + writer.line('merged[key] = value'); + }, + '}' + ); + writer.line( + 'return send(ctx, &c.config, requestSpec{OperationID: op.ID, Method: op.Method, URL: requestURL, Headers: merged, Query: query})' + ); + }, + '}' + ); + writer.line( + `return iterSSE(open, ${sse.schema !== undefined && sse.schema.kind !== 'unknown'})` + ); + return; + } const specFields = [ 'OperationID: op.ID', 'Method: op.Method', @@ -353,7 +416,18 @@ function writeGoMethod(writer: CodeWriter, op: OperationModel, ident: string): v 'Headers: authHeaders', 'Query: query', ]; - if (op.requestBody) { + if (op.requestBody && isMultipart(op)) { + writer.line('contentType, reader, err := toMultipart(body)'); + writer.block( + 'if err != nil {', + () => { + writer.line(fail('err')); + }, + '}' + ); + specFields.push('Body: reader'); + specFields.push('ContentType: contentType'); + } else if (op.requestBody) { writer.line('payload, err := json.Marshal(body)'); writer.block( 'if err != nil {', @@ -398,9 +472,199 @@ function writeGoMethod(writer: CodeWriter, op: OperationModel, ident: string): v writer.blank(); } +/** `Pages` / `Items` iterators over the runtime's `iterPages`, hydrated via `reencode`. */ +function writeGoPaginationWrappers( + writer: CodeWriter, + op: OperationModel, + ident: string, + pageType: string, + itemType: string +): void { + const pathArgs = op.pathParams.map((param) => ({ + param, + go: identifierFor(param.name, { style: 'camel', reserved: GO }), + type: goType(param.schema), + })); + const hasParams = op.queryParams.length > 0; + const args = [ + 'ctx context.Context', + ...pathArgs.map(({ go, type }) => `${go} ${type}`), + ...(hasParams ? [`params *${ident}Params`] : []), + ].join(', '); + + const writeCallClosure = () => { + writer.line(`op := operations[${JSON.stringify(op.specName ?? op.name)}]`); + writer.line('base := url.Values{}'); + if (hasParams) { + writer.block( + 'if params != nil {', + () => { + for (const param of op.queryParams) { + const field = exported(param.name); + writer.block( + `if params.${field} != nil {`, + () => { + writer.line( + `base.Set(${JSON.stringify(param.name)}, ${goQueryFormat(`*params.${field}`, goType(param.schema))})` + ); + }, + '}' + ); + } + }, + '}' + ); + } + writer.block( + 'call := func(pageParams url.Values) (any, *http.Response, error) {', + () => { + writer.line('authHeaders, query := resolveAuth(op.Security, c.config.Auth)'); + writer.block( + 'for key, values := range pageParams {', + () => { + writer.block( + 'for _, value := range values {', + () => { + writer.line('query.Set(key, value)'); + }, + '}' + ); + }, + '}' + ); + const pathDict = pathArgs + .map(({ param, go, type }) => `${JSON.stringify(param.name)}: ${goQueryFormat(go, type)}`) + .join(', '); + writer.line( + `requestURL := buildURL(c.config.ServerURL, op.Path, map[string]string{${pathDict}})` + ); + writer.line( + 'resp, err := send(ctx, &c.config, requestSpec{OperationID: op.ID, Method: op.Method, URL: requestURL, Headers: authHeaders, Query: query})' + ); + writer.block( + 'if err != nil {', + () => { + writer.line('return nil, nil, err'); + }, + '}' + ); + writer.block( + 'if resp.StatusCode >= 400 {', + () => { + writer.line('return nil, resp, apiErrorFrom(resp, requestURL)'); + }, + '}' + ); + writer.line('var raw any'); + writer.block( + 'if err := decodeJSON(resp, &raw); err != nil {', + () => { + writer.line('return nil, resp, err'); + }, + '}' + ); + writer.line('return raw, resp, nil'); + }, + '}' + ); + writer.line('pages := iterPages(call, *op.Pagination, base)'); + }; + + writer.line( + `// ${ident}Pages iterates ${ident} response pages; use with \`for page, err := range\`.` + ); + writer.block( + `func (c *Client) ${ident}Pages(${args}) func(yield func(${pageType}, error) bool) {`, + () => { + writeCallClosure(); + writer.block( + `return func(yield func(${pageType}, error) bool) {`, + () => { + writer.block( + 'pages(func(raw any, err error) bool {', + () => { + writer.line(`var page ${pageType}`); + writer.block( + 'if err == nil {', + () => { + writer.line('err = reencode(raw, &page)'); + }, + '}' + ); + writer.line('return yield(page, err)'); + }, + '})' + ); + }, + '}' + ); + }, + '}' + ); + writer.blank(); + + writer.line(`// ${ident}Items iterates the items of every ${ident} page.`); + writer.block( + `func (c *Client) ${ident}Items(${args}) func(yield func(${itemType}, error) bool) {`, + () => { + writeCallClosure(); + writer.block( + `return func(yield func(${itemType}, error) bool) {`, + () => { + writer.block( + 'pages(func(raw any, err error) bool {', + () => { + writer.block( + 'if err != nil {', + () => { + writer.line(`var zero ${itemType}`); + writer.line('return yield(zero, err)'); + }, + '}' + ); + writer.line('pageItems, _ := resolvePointer(raw, op.Pagination.Items).([]any)'); + writer.block( + 'for _, item := range pageItems {', + () => { + writer.line(`var typed ${itemType}`); + writer.block( + 'if err := reencode(item, &typed); err != nil {', + () => { + writer.line('return yield(typed, err)'); + }, + '}' + ); + writer.block( + 'if !yield(typed, nil) {', + () => { + writer.line('return false'); + }, + '}' + ); + }, + '}' + ); + writer.line('return true'); + }, + '})' + ); + }, + '}' + ); + }, + '}' + ); + writer.blank(); +} + /** The whole generated file: models + embedded runtime + operations table + Client. */ -export const goGenerator: Generator = ({ model, outputPath }) => { +export const goGenerator: Generator = ({ model, outputPath, emit }) => { const writer = new CodeWriter('\t'); + 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); + } writer.line( `// Code generated by @redocly/client-generator (go) from "${model.title}" ${model.version}. DO NOT EDIT.` ); @@ -422,6 +686,7 @@ export const goGenerator: Generator = ({ model, outputPath }) => { 'fmt', 'io', 'math/rand', + 'mime/multipart', 'net/http', 'net/url', 'strconv', @@ -444,10 +709,11 @@ export const goGenerator: Generator = ({ model, outputPath }) => { writer.block( 'type operationMeta struct {', () => { - writer.line('ID string'); - writer.line('Method string'); - writer.line('Path string'); - writer.line('Security [][]SecuritySpec'); + writer.line('ID string'); + writer.line('Method string'); + writer.line('Path string'); + writer.line('Security [][]SecuritySpec'); + writer.line('Pagination *PaginationSpec'); }, '}' ); @@ -455,14 +721,16 @@ export const goGenerator: Generator = ({ model, outputPath }) => { writer.block( 'var operations = map[string]operationMeta{', () => { - for (const { op } of goOperationIdents(model)) { + for (const { op, ident } of goOperationIdents(model)) { const id = op.specName ?? op.name; const security = goSecurityLiteral(op, model); + const rule = paginationRules.get(ident); const fields = [ `ID: ${JSON.stringify(id)}`, `Method: ${JSON.stringify(op.method.toUpperCase())}`, `Path: ${JSON.stringify(op.path)}`, ...(security !== undefined ? [`Security: ${security}`] : []), + ...(rule !== undefined ? [`Pagination: ${goPaginationLiteral(rule)}`] : []), ]; writer.line(`${JSON.stringify(id)}: {${fields.join(', ')}},`); } @@ -516,6 +784,24 @@ export const goGenerator: Generator = ({ model, outputPath }) => { for (const { op, ident } of goOperationIdents(model)) { writeGoMethod(writer, op, ident); + const rule = paginationRules.get(ident); + if (rule === undefined) continue; + const success = successSchema(op); + const pageType = success === undefined ? 'any' : goType(success); + // Resolve the items ARRAY, then take its raw element, so a `ref` element + // keeps its name (a deref'd result would type as `any`). + const itemsArray = + success !== undefined && rule.items !== undefined + ? schemaAtPointer(success, rule.items, model) + : undefined; + const element = itemsArray?.kind === 'array' ? itemsArray.items : undefined; + writeGoPaginationWrappers( + writer, + op, + ident, + pageType, + element === undefined ? 'any' : goType(element) + ); } return [{ path: outputPath.replace(/\.[^.\\/]+$/, '.go'), content: writer.toString() }]; From a777e4bcec27c895340adb220a34c3178509a1d9 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 16:53:53 +0300 Subject: [PATCH 022/211] fix(client-generator): treat strict-mode reserved words as reserved in generated TypeScript --- .../src/emitters/__tests__/identifier.test.ts | 7 +++++++ packages/client-generator/src/emitters/identifier.ts | 10 ++++++++++ 2 files changed, 17 insertions(+) diff --git a/packages/client-generator/src/emitters/__tests__/identifier.test.ts b/packages/client-generator/src/emitters/__tests__/identifier.test.ts index 0aeee91550..fa5bc15b80 100644 --- a/packages/client-generator/src/emitters/__tests__/identifier.test.ts +++ b/packages/client-generator/src/emitters/__tests__/identifier.test.ts @@ -49,6 +49,13 @@ describe('uniqueIdent', () => { expect(uniqueIdent('new', new Set())).toBe('_new'); }); + it('treats strict-mode reserved words as reserved (modules are always strict)', () => { + // GitHub's real description has a schema named `package`; `type X = package[]` is TS1214. + expect(uniqueIdent('package', new Set())).toBe('_package'); + expect(uniqueIdent('let', new Set())).toBe('_let'); + expect(uniqueIdent('await', new Set())).toBe('_await'); + }); + it('suffixes collisions with an incrementing counter', () => { const used = new Set(); expect(uniqueIdent('a.b', used)).toBe('a_b'); diff --git a/packages/client-generator/src/emitters/identifier.ts b/packages/client-generator/src/emitters/identifier.ts index 71967dfdf7..1130855593 100644 --- a/packages/client-generator/src/emitters/identifier.ts +++ b/packages/client-generator/src/emitters/identifier.ts @@ -43,6 +43,16 @@ const TS_RESERVED = new Set([ 'while', 'with', 'yield', + // Strict-mode reserved words — generated files are ES modules, always strict. + 'await', + 'implements', + 'interface', + 'let', + 'package', + 'private', + 'protected', + 'public', + 'static', ]); /** True when `name` matches the JS identifier grammar (reserved words still pass). */ From bd9ac350fc1d1b53f002ff5c445850fa60015bdf Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 16:56:17 +0300 Subject: [PATCH 023/211] fix(client-generator): name signed-number properties Plus*/Minus* instead of colliding --- .../src/authoring/__tests__/naming.test.ts | 9 +++++++++ .../client-generator/src/authoring/naming.ts | 18 ++++++++++++------ .../src/generators/__tests__/go.test.ts | 17 +++++++++++++++++ .../src/generators/__tests__/python.test.ts | 19 +++++++++++++++++++ 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/packages/client-generator/src/authoring/__tests__/naming.test.ts b/packages/client-generator/src/authoring/__tests__/naming.test.ts index 919a30b8f0..fc7bc4d723 100644 --- a/packages/client-generator/src/authoring/__tests__/naming.test.ts +++ b/packages/client-generator/src/authoring/__tests__/naming.test.ts @@ -11,6 +11,15 @@ describe('casing', () => { expect(casing.snake('APIKey')).toBe('api_key'); expect(casing.pascal('api_key_v2')).toBe('ApiKeyV2'); }); + + it('names signed numbers Plus*/Minus* so +1 and -1 stay distinct (GitHub reactions)', () => { + expect(casing.pascal('+1')).toBe('Plus1'); + expect(casing.pascal('-1')).toBe('Minus1'); + expect(casing.snake('+1')).toBe('plus_1'); + expect(casing.screaming('-1')).toBe('MINUS_1'); + // A minus that is just a word delimiter is untouched. + expect(casing.pascal('x-header')).toBe('XHeader'); + }); }); describe('identifierFor', () => { diff --git a/packages/client-generator/src/authoring/naming.ts b/packages/client-generator/src/authoring/naming.ts index d0d38d0893..2cfa919486 100644 --- a/packages/client-generator/src/authoring/naming.ts +++ b/packages/client-generator/src/authoring/naming.ts @@ -5,12 +5,18 @@ /** Split on delimiters and camel/acronym boundaries: 'APIKey-v2' → ['api', 'key', 'v2']. */ function splitWords(name: string): string[] { - return name - .replace(/([a-z0-9])([A-Z])/g, '$1 $2') - .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') - .split(/[^A-Za-z0-9]+/) - .filter((word) => word !== '') - .map((word) => word.toLowerCase()); + return ( + name + // A leading sign on a number is meaning, not a delimiter: '+1'/'-1' (GitHub + // reactions) must not collapse to the same identifier. + .replace(/^\+(?=\d)/, 'plus ') + .replace(/^-(?=\d)/, 'minus ') + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .split(/[^A-Za-z0-9]+/) + .filter((word) => word !== '') + .map((word) => word.toLowerCase()) + ); } const capitalize = (word: string) => word.charAt(0).toUpperCase() + word.slice(1); diff --git a/packages/client-generator/src/generators/__tests__/go.test.ts b/packages/client-generator/src/generators/__tests__/go.test.ts index 72cb766c59..60e5cbcf36 100644 --- a/packages/client-generator/src/generators/__tests__/go.test.ts +++ b/packages/client-generator/src/generators/__tests__/go.test.ts @@ -114,6 +114,23 @@ describe('renderGoModels', () => { expectGoCompiles(out); }); + it('keeps +1 and -1 fields distinct and exported (GitHub reactions)', () => { + const out = renderGoModels( + model({ + Reactions: { + kind: 'object', + properties: [ + { name: '+1', schema: INT, required: true }, + { name: '-1', schema: INT, required: true }, + ], + }, + }) + ); + expect(out).toContain('Plus1 int64 `json:"+1"`'); + expect(out).toContain('Minus1 int64 `json:"-1"`'); + expectGoCompiles(out); + }); + it('maps nullability and records to pointers and maps', () => { const out = renderGoModels( model({ diff --git a/packages/client-generator/src/generators/__tests__/python.test.ts b/packages/client-generator/src/generators/__tests__/python.test.ts index 1965bf09cd..26a06bdded 100644 --- a/packages/client-generator/src/generators/__tests__/python.test.ts +++ b/packages/client-generator/src/generators/__tests__/python.test.ts @@ -129,6 +129,25 @@ describe('renderPythonModels', () => { expectCompiles(out); }); + it('keeps +1 and -1 fields distinct (a collision silently drops one from the field map)', () => { + const out = renderPythonModels( + model({ + Reactions: { + kind: 'object', + properties: [ + { name: '+1', schema: INT, required: true }, + { name: '-1', schema: INT, required: true }, + ], + }, + }) + ); + expect(out).toContain('plus_1: int'); + expect(out).toContain('minus_1: int'); + expect(out).toContain('"plus_1": "+1"'); + expect(out).toContain('"minus_1": "-1"'); + expectCompiles(out); + }); + it('renders nullable and record shapes idiomatically', () => { const out = renderPythonModels( model({ From aacf152ea081cd0774aef6424b7dc20dbb737b65 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 16:58:31 +0300 Subject: [PATCH 024/211] fix(client-generator): export Go fields for digit-leading property names --- .../src/generators/__tests__/go.test.ts | 13 +++++++++++++ packages/client-generator/src/generators/go.ts | 5 ++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/client-generator/src/generators/__tests__/go.test.ts b/packages/client-generator/src/generators/__tests__/go.test.ts index 60e5cbcf36..16fc64eb9c 100644 --- a/packages/client-generator/src/generators/__tests__/go.test.ts +++ b/packages/client-generator/src/generators/__tests__/go.test.ts @@ -114,6 +114,19 @@ describe('renderGoModels', () => { expectGoCompiles(out); }); + it('exports digit-leading field names with an N prefix (an _-prefixed field is invisible to encoding/json)', () => { + const out = renderGoModels( + model({ + PaymentMethod: { + kind: 'object', + properties: [{ name: '3ds', schema: STRING, required: false }], + }, + }) + ); + expect(out).toContain('N3ds *string `json:"3ds,omitempty"`'); + expectGoCompiles(out); + }); + it('keeps +1 and -1 fields distinct and exported (GitHub reactions)', () => { const out = renderGoModels( model({ diff --git a/packages/client-generator/src/generators/go.ts b/packages/client-generator/src/generators/go.ts index 66958e39c4..2018096d7f 100644 --- a/packages/client-generator/src/generators/go.ts +++ b/packages/client-generator/src/generators/go.ts @@ -32,7 +32,10 @@ const GO = RESERVED_WORDS.go; /** An exported Go identifier (PascalCase; keywords can't collide since these start uppercase). */ function exported(name: string): string { - return identifierFor(name, { style: 'pascal', reserved: GO }); + 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. */ From 0a1e1a15e3214bba8c399e845230e2c75030ed6a Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 17:00:52 +0300 Subject: [PATCH 025/211] test: add generate-client verification harness against Rebilly and GitHub descriptions --- package.json | 1 + tests/harness/.gitignore | 1 + .../harness/generate-client/github.harness.ts | 31 ++++++++ tests/harness/generate-client/helpers.ts | 70 +++++++++++++++++++ .../generate-client/rebilly.harness.ts | 24 +++++++ vitest.config.ts | 7 ++ 6 files changed, 134 insertions(+) create mode 100644 tests/harness/.gitignore create mode 100644 tests/harness/generate-client/github.harness.ts create mode 100644 tests/harness/generate-client/helpers.ts create mode 100644 tests/harness/generate-client/rebilly.harness.ts diff --git a/package.json b/package.json index 9edaef6c7b..ca3b3a1769 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "unit": "VITEST_SUITE=unit vitest run", "e2e": "VITEST_SUITE=e2e vitest run", "smoke:rebilly": "VITEST_SUITE=smoke-rebilly vitest run", + "harness": "VITEST_SUITE=harness vitest run", "format": "oxfmt .", "format:check": "oxfmt --check .", "lint": "oxlint ./packages", diff --git a/tests/harness/.gitignore b/tests/harness/.gitignore new file mode 100644 index 0000000000..ceddaa37f1 --- /dev/null +++ b/tests/harness/.gitignore @@ -0,0 +1 @@ +.cache/ diff --git a/tests/harness/generate-client/github.harness.ts b/tests/harness/generate-client/github.harness.ts new file mode 100644 index 0000000000..71153816d1 --- /dev/null +++ b/tests/harness/generate-client/github.harness.ts @@ -0,0 +1,31 @@ +// GitHub's REST description (~1000 operations, downloaded at a pinned SHA) — the +// scale case that shook out the strict-mode reserved-word and +1/-1 naming bugs. + +import { + fetchGithubDescription, + goBar, + hasGo, + hasPython, + pythonBar, + typescriptBar, +} from './helpers.js'; + +let github: string; + +beforeAll(async () => { + github = await fetchGithubDescription(); +}); + +describe('github REST description', () => { + it('sdk (TypeScript) passes strict tsc', () => { + typescriptBar(github); + }); + + it.skipIf(!hasPython)('python imports cleanly', () => { + pythonBar(github); + }); + + it.skipIf(!hasGo)('go builds and vets cleanly', () => { + goBar(github); + }); +}); diff --git a/tests/harness/generate-client/helpers.ts b/tests/harness/generate-client/helpers.ts new file mode 100644 index 0000000000..0624b20809 --- /dev/null +++ b/tests/harness/generate-client/helpers.ts @@ -0,0 +1,70 @@ +// Certification-bar helpers: generate a client from a real-world description and +// hold each language's output to a compile bar. Runs as its own vitest suite +// (`npm run harness`) and CI workflow — never inside the regular e2e job. + +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { generate, strictTypecheck } from '../../e2e/generate-client/helpers.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** Pinned commit of github/rest-api-description; bump deliberately. */ +export const GITHUB_DESCRIPTION_SHA = '5e28810649ba41b5483753ba74f976f83856a504'; + +const cacheDir = join(__dirname, '../.cache'); + +/** Download `api.github.com.json` at the pinned SHA once; later runs hit the cache. */ +export async function fetchGithubDescription(): Promise { + const cached = join(cacheDir, `api.github.com-${GITHUB_DESCRIPTION_SHA.slice(0, 12)}.json`); + if (existsSync(cached)) return cached; + const url = `https://raw.githubusercontent.com/github/rest-api-description/${GITHUB_DESCRIPTION_SHA}/descriptions/api.github.com/api.github.com.json`; + const response = await fetch(url); + if (!response.ok) throw new Error(`Failed to download ${url}: ${response.status}`); + mkdirSync(cacheDir, { recursive: true }); + writeFileSync(cached, Buffer.from(await response.arrayBuffer())); + return cached; +} + +export const hasPython = spawnSync('python3', ['--version']).status === 0; +export const hasHttpx = hasPython && spawnSync('python3', ['-c', 'import httpx']).status === 0; +export const hasGo = spawnSync('go', ['version']).status === 0; + +/** Generate with `--generator ` into a fresh temp dir; returns the dir. */ +export function generateWith(generator: string, description: string): string { + const dir = mkdtempSync(join(tmpdir(), `harness-${generator}-`)); + generate(description, join(dir, 'client.ts'), ['--generator', generator]); + return dir; +} + +/** TS bar: the generated client passes a strict `tsc --noEmit`. */ +export function typescriptBar(description: string): void { + const dir = generateWith('sdk', description); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ type: 'module' }), 'utf-8'); + strictTypecheck(dir); +} + +/** + * Python bar: `import client` (executes every dataclass declaration — catches + * duplicate fields and bad defaults); syntax-only `py_compile` when httpx is absent. + */ +export function pythonBar(description: string): void { + const dir = generateWith('python', description); + const check = hasHttpx + ? spawnSync('python3', ['-c', 'import client'], { cwd: dir, encoding: 'utf-8' }) + : spawnSync('python3', ['-m', 'py_compile', 'client.py'], { cwd: dir, encoding: 'utf-8' }); + expect(check.status, check.stderr).toBe(0); +} + +/** Go bar: `go build` + `go vet` (vet catches json tags on unexported fields). */ +export function goBar(description: string): void { + const dir = generateWith('go', description); + writeFileSync(join(dir, 'go.mod'), 'module harness.test\n\ngo 1.21\n', 'utf-8'); + const build = spawnSync('go', ['build', './...'], { cwd: dir, encoding: 'utf-8' }); + expect(build.status, build.stderr).toBe(0); + const vet = spawnSync('go', ['vet', './...'], { cwd: dir, encoding: 'utf-8' }); + expect(vet.status, vet.stderr).toBe(0); +} diff --git a/tests/harness/generate-client/rebilly.harness.ts b/tests/harness/generate-client/rebilly.harness.ts new file mode 100644 index 0000000000..e6134eff8c --- /dev/null +++ b/tests/harness/generate-client/rebilly.harness.ts @@ -0,0 +1,24 @@ +// Rebilly (vendored, 638 operations, allOf-heavy) — the real-world description +// that shook out the allOf pagination fix and the Go `3ds` field-export bug. + +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { goBar, hasGo, hasPython, pythonBar, typescriptBar } from './helpers.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const rebilly = join(__dirname, '../../smoke/rebilly/rebilly-description.yaml'); + +describe('rebilly description', () => { + it('sdk (TypeScript) passes strict tsc', () => { + typescriptBar(rebilly); + }); + + it.skipIf(!hasPython)('python imports cleanly', () => { + pythonBar(rebilly); + }); + + it.skipIf(!hasGo)('go builds and vets cleanly', () => { + goBar(rebilly); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index a79c631097..303fab4ef1 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -39,6 +39,13 @@ const configExtension: { [key: string]: ViteUserConfig } = { include: ['tests/smoke/rebilly/**/*.smoke.ts'], }, }), + harness: defineConfig({ + test: { + include: ['tests/harness/**/*.harness.ts'], + testTimeout: 300_000, + hookTimeout: 300_000, + }, + }), default: defineConfig({}), }; From 0ac71906e34639c6646ce5cd84a75cfc7101400d Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 17:08:06 +0300 Subject: [PATCH 026/211] ci: run the generate-client harness on generator-path changes --- .github/workflows/harness.yaml | 48 ++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .github/workflows/harness.yaml diff --git a/.github/workflows/harness.yaml b/.github/workflows/harness.yaml new file mode 100644 index 0000000000..cf786fcacd --- /dev/null +++ b/.github/workflows/harness.yaml @@ -0,0 +1,48 @@ +name: Generate-client harness + +permissions: + contents: read + +on: + pull_request: + paths: + - 'packages/client-generator/**' + - 'tests/harness/**' + - '.github/workflows/harness.yaml' + workflow_dispatch: + +env: + CI: true + REDOCLY_TELEMETRY: off + +jobs: + run-harness: + # Only run if PR is from the same repository (not a fork) + if: ${{ github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 24 + cache: npm + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + with: + go-version: stable + cache: false + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.12' + - name: Install httpx (the Python import bar needs it) + run: pip install httpx + - name: Cache the pinned GitHub REST description + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: tests/harness/.cache + key: harness-github-description-${{ hashFiles('tests/harness/generate-client/helpers.ts') }} + - name: Install dependencies + run: npm ci + - name: Compile + run: npm run compile + - name: Run harness + run: npm run harness From 9e399d50b54af4137fb533fb95290c63a3035d4b Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 17:13:09 +0300 Subject: [PATCH 027/211] test: update config schema snapshot for the client codeSamples option --- .changeset/harness-naming-fixes.md | 6 ++++++ .../src/__tests__/__snapshots__/redocly-yaml.test.ts.snap | 3 +++ 2 files changed, 9 insertions(+) create mode 100644 .changeset/harness-naming-fixes.md diff --git a/.changeset/harness-naming-fixes.md b/.changeset/harness-naming-fixes.md new file mode 100644 index 0000000000..677a883050 --- /dev/null +++ b/.changeset/harness-naming-fixes.md @@ -0,0 +1,6 @@ +--- +'@redocly/client-generator': patch +'@redocly/cli': patch +--- + +Fixed three naming bugs found by generating clients from real-world API descriptions: strict-mode reserved words (such as `package`) are now sanitized in generated TypeScript, `+1`/`-1` property names become distinct `Plus1`/`Minus1` identifiers instead of colliding (the Python client silently dropped one of the fields), and digit-leading property names (such as `3ds`) produce exported Go struct fields instead of unexported ones that `encoding/json` ignores. 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 bc90eff282..a665ffabb4 100644 --- a/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap +++ b/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap @@ -219,6 +219,9 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "grouped", ], }, + "codeSamples": { + "type": "boolean", + }, "dateType": { "enum": [ "string", From 08752c71bb07af25740592ec80d76f3fc41e456d Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 17:46:00 +0300 Subject: [PATCH 028/211] feat(client-generator): zero-dep cli runtime (parser, dispatch, exit codes) --- .../scripts/generate-runtime-sources.mjs | 1 + .../src/emitters/reserved-names.ts | 2 + .../src/emitters/runtime-sources.ts | 15 + packages/client-generator/src/index.ts | 3 + .../src/runtime/__tests__/cli.test.ts | 338 ++++++++++++++ packages/client-generator/src/runtime/cli.ts | 429 ++++++++++++++++++ 6 files changed, 788 insertions(+) create mode 100644 packages/client-generator/src/runtime/__tests__/cli.test.ts create mode 100644 packages/client-generator/src/runtime/cli.ts diff --git a/packages/client-generator/scripts/generate-runtime-sources.mjs b/packages/client-generator/scripts/generate-runtime-sources.mjs index 48f2b4904d..45ffd72764 100644 --- a/packages/client-generator/scripts/generate-runtime-sources.mjs +++ b/packages/client-generator/scripts/generate-runtime-sources.mjs @@ -20,6 +20,7 @@ const MODULES = [ 'sse', 'create-client', 'paginate', + 'cli', ]; const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); diff --git a/packages/client-generator/src/emitters/reserved-names.ts b/packages/client-generator/src/emitters/reserved-names.ts index e1971d255c..7fa5049d5f 100644 --- a/packages/client-generator/src/emitters/reserved-names.ts +++ b/packages/client-generator/src/emitters/reserved-names.ts @@ -68,6 +68,7 @@ const GLOBAL_NAMES = [ 'ArrayBuffer', 'ArrayBufferView', 'AsyncGenerator', + 'AsyncIterable', 'Blob', 'BodyInit', 'Boolean', @@ -99,6 +100,7 @@ const GLOBAL_NAMES = [ 'TextDecoder', 'TextEncoder', 'TypeError', + 'Uint8Array', 'URL', 'URLSearchParams', 'btoa', diff --git a/packages/client-generator/src/emitters/runtime-sources.ts b/packages/client-generator/src/emitters/runtime-sources.ts index 4fb9da9190..b31eaf6eb2 100644 --- a/packages/client-generator/src/emitters/runtime-sources.ts +++ b/packages/client-generator/src/emitters/runtime-sources.ts @@ -24,6 +24,8 @@ export const RUNTIME_SOURCES = { "import { ApiError } from './errors.js';\nimport { parse, readError } from './parse.js';\nimport { middlewareChain, send, toHeaderRecord, type SendCapabilities } from './send.js';\nimport type {\n ApiErrorLike,\n Client,\n ClientConfig,\n Middleware,\n OperationContext,\n OperationDescriptor,\n OpsShape,\n PaginationSpec,\n ParseAs,\n QueryValue,\n RequestOptions,\n ResponseHeaderSpec,\n SecuritySpec,\n ServerSentEvent,\n SseOptions,\n TokenProvider,\n} from './types.js';\nimport { buildUrl, substitutePath, type QueryStyle } from './url.js';\n\n/**\n * The optional behaviors `createClientCore` can dispatch to but never statically\n * imports. The package's public `createClient` wires the full set; the future\n * inline-mode assembler wires only the capabilities a spec needs.\n */\nexport type Capabilities = SendCapabilities & {\n resolveAuth?: (\n security: readonly (readonly SecuritySpec[])[],\n config: ClientConfig\n ) => Promise<{ headers: Record; query: Record }>;\n sse?: (\n config: ClientConfig,\n op: OperationContext,\n // Re-preparing per (re)connect (not a frozen url/init) lets a refresh-style\n // TokenProvider issue a fresh credential after a dropped stream reconnects.\n prepare: () => Promise<{ url: string; init: SseOptions }>,\n dataKind: 'json' | 'text'\n ) => AsyncGenerator>;\n paginate?: {\n pages: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n items: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n // The `link`-style iterators need the raw `Link` header + page URL, which the\n // parsed-page call above cannot carry (the shape mirrors paginate's `LinkPageCall`).\n pagesByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n itemsByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n };\n};\n\n/** The grouped args wire shape: path params by name plus the `params`/`body`/`headers`/`cookies` slots. */\nexport type OperationArgs = {\n params?: Record;\n body?: unknown;\n headers?: Record;\n cookies?: Record;\n} & Record;\n\n/** The response reader implied by the descriptor (before any per-call `parseAs` override). */\n/**\n * The `Accept` header matching how the response will be read — a blob/text operation\n * must not ask for `application/json` (a content-negotiating server would 406 or\n * answer with a JSON error body instead of the payload). Caller `init.headers` and\n * `config.headers` still override.\n */\nfunction acceptFor(kind: ParseAs | 'void'): string {\n if (kind === 'text') return 'text/*';\n if (kind === 'blob' || kind === 'arrayBuffer' || kind === 'stream' || kind === 'formData') {\n return '*/*';\n }\n return 'application/json'; // json | auto | void\n}\n\nfunction kindFor(op: OperationDescriptor): ParseAs | 'void' {\n if (op.responseKind === 'void' || op.responseKind === 'blob' || op.responseKind === 'text') {\n return op.responseKind;\n }\n return 'auto';\n}\n\n/** Route the grouped args by the descriptor: path values, query object, body, extra headers, cookies. */\nfunction splitArgs(op: OperationDescriptor, args: OperationArgs) {\n const path: Record = {};\n const pathNames = new Set();\n for (const param of op.params ?? []) {\n if (param.in === 'path') {\n pathNames.add(param.name);\n path[param.name] = args[param.name];\n }\n }\n // An unknown top-level key can only be a bug (usually a flat-style call shape passed\n // to a grouped client: `{ limit: 10 }` instead of `{ params: { limit: 10 } }`).\n // TypeScript catches it, but transpilers that skip type-checking would otherwise\n // ship a request that silently drops the value — fail the call loudly instead.\n for (const key of Object.keys(args)) {\n if (key === 'params' || key === 'body' || key === 'headers' || key === 'cookies') continue;\n if (pathNames.has(key)) continue;\n throw new TypeError(\n `Unknown argument \"${key}\" for operation \"${op.id}\". Query parameters go under params: { … } and the request body under body; valid keys are params, body, headers, cookies` +\n (pathNames.size > 0 ? `, and the path parameters (${[...pathNames].join(', ')}).` : '.')\n );\n }\n return {\n path,\n query: args.params,\n body: args.body,\n headers: args.headers,\n cookies: args.cookies,\n };\n}\n\n/**\n * The query-serialization hints for the descriptor's query params. A spec is built only\n * when the param deviates from the OpenAPI defaults (`form` + `explode: true`, encoded),\n * and always fully resolved — so `explode: false` or `allowReserved` alone (no `style`)\n * are honored, and an omitted `explode` keeps the exploded default.\n */\nfunction queryStyles(op: OperationDescriptor): Record | undefined {\n let styles: Record | undefined;\n for (const param of op.params ?? []) {\n if (param.in !== 'query') continue;\n const deviates =\n (param.style !== undefined && param.style !== 'form') ||\n param.explode === false ||\n param.allowReserved === true;\n if (!deviates) continue;\n styles ??= {};\n styles[param.name] = {\n style: param.style ?? 'form',\n explode: param.explode ?? true,\n allowReserved: param.allowReserved,\n };\n }\n return styles;\n}\n\n/** Stringify caller-supplied extra headers, skipping empty entries. */\nfunction stringHeaders(headers: Record | undefined): Record {\n const out: Record = {};\n for (const [key, value] of Object.entries(headers ?? {})) {\n if (value !== undefined && value !== null) out[key] = String(value);\n }\n return out;\n}\n\n/** Build the request pieces an attempt needs: the final URL and the merged per-call init. */\nasync function prepareRequest(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions | SseOptions,\n caps: Capabilities\n): Promise<{ url: string; init: RequestOptions; body: unknown }> {\n const { path, query, body, headers, cookies } = splitArgs(op, args);\n const authed: { headers: Record; query: Record } =\n op.security?.length && caps.resolveAuth\n ? await caps.resolveAuth(op.security, config)\n : { headers: {}, query: {} };\n // Cookie params join the auth-injected cookies in one `Cookie` header (values\n // percent-encoded, like auth cookies). Server-side only — browsers own the header.\n const cookiePairs = Object.entries(cookies ?? {})\n .filter(([, value]) => value !== undefined && value !== null)\n .map(([cookieName, value]) => `${cookieName}=${encodeURIComponent(String(value))}`);\n if (cookiePairs.length > 0) {\n authed.headers.Cookie = [authed.headers.Cookie, ...cookiePairs].filter(Boolean).join('; ');\n }\n const fullQuery: Record = { ...query, ...authed.query };\n const url = buildUrl(\n config.serverUrl ?? '',\n substitutePath(op.path, path),\n Object.keys(fullQuery).length > 0 ? fullQuery : undefined,\n queryStyles(op)\n );\n const mergedInit: RequestOptions = {\n ...init,\n method: op.method.toUpperCase(),\n // Precedence, lowest → highest (later spreads win): injected auth → explicit\n // header params → caller `init.headers` — the caller always overrides both.\n headers: {\n ...authed.headers,\n ...stringHeaders(headers),\n ...toHeaderRecord(init.headers),\n },\n };\n return { url, init: mergedInit, body };\n}\n\n/** Coerce a single declared response header value; omit when absent or unparsable. */\nfunction coerceResponseHeader(\n raw: string | null,\n type: ResponseHeaderSpec['type']\n): string | number | boolean | undefined {\n if (raw === null) return undefined;\n if (type === 'number') {\n if (raw.trim() === '') return undefined;\n const value = Number(raw);\n return Number.isFinite(value) ? value : undefined;\n }\n if (type === 'boolean') {\n const value = raw.trim().toLowerCase();\n if (value === 'true') return true;\n if (value === 'false') return false;\n return undefined;\n }\n return raw;\n}\n\n/** Build the camelCase declared-header bag for a throw-mode envelope. */\nfunction readEnvelopeHeaders(\n response: Response,\n specs: readonly ResponseHeaderSpec[] | undefined\n): Record {\n const headers: Record = {};\n for (const spec of specs ?? []) {\n const value = coerceResponseHeader(response.headers.get(spec.name), spec.type);\n if (value !== undefined) headers[spec.key] = value;\n }\n return headers;\n}\n\n/** One non-SSE call: send, then branch on the configured error mode. */\nasync function execute(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions,\n caps: Capabilities\n): Promise {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // `parseAs` / `envelope` are client options, not fetch RequestInit fields.\n const { parseAs, envelope, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const { response, context } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (config.errorMode === 'result') {\n if (!response.ok) {\n return { data: undefined, error: await readError(response), response };\n }\n return { data: await parse(response, readKind), error: undefined, response };\n }\n if (!response.ok) {\n let error: globalThis.Error = new ApiError(\n context.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n // Thread the error through each middleware's onError in turn (each may replace it).\n for (const mw of middlewareChain(config)) {\n if (mw.onError) error = await mw.onError(error as ApiErrorLike, context);\n }\n throw error;\n }\n const data = await parse(response, readKind);\n if (envelope === true) {\n return {\n data,\n headers: readEnvelopeHeaders(response, op.responseHeaders),\n response,\n };\n }\n return data;\n}\n\n/** The paginate capability, or a descriptive throw when a paginated op is iterated unwired. */\nfunction paginateCapability(caps: Capabilities, op: OperationDescriptor) {\n if (!caps.paginate) {\n throw new Error(`Pagination capability not wired: cannot iterate operation \"${op.id}\"`);\n }\n return caps.paginate;\n}\n\n/**\n * The per-page call the iterators drive: the method itself in throw mode; in result\n * mode a wrapper that unwraps the `{ data, error, response }` envelope — the page\n * pointers are data-rooted — rethrowing a failed page as `ApiError` (iteration is\n * error-mode-agnostic; the throw-mode-only `onError` middleware hook is not invoked).\n */\nfunction pageCall(\n method: (args?: OperationArgs, init?: RequestOptions) => Promise,\n config: ClientConfig\n) {\n const callWithoutEnvelope = (args?: OperationArgs, init?: RequestOptions) => {\n if (!init || init.envelope === undefined) return method(args, init);\n const { envelope: _envelope, ...pageInit } = init;\n return method(args, pageInit);\n };\n if (config.errorMode !== 'result') return callWithoutEnvelope;\n return async (args?: OperationArgs, init?: RequestOptions) => {\n const envelope = (await callWithoutEnvelope(args, init)) as {\n data: unknown;\n error: unknown;\n response: Response;\n };\n // Failure is `!response.ok` — NOT `data === undefined`: a successful bodyless page\n // (204/void) also parses to undefined data, and a failed page's `error` can be\n // undefined too (unreadable body). The pointers then miss on the undefined data\n // and iteration stops cleanly, which is the correct semantics for an empty page.\n if (!envelope.response.ok) {\n const { response } = envelope;\n throw new ApiError(response.url, response.status, response.statusText, envelope.error);\n }\n return envelope.data;\n };\n}\n\n/**\n * The per-page call the `link`-style iterators drive: like `execute`, but returning the\n * parsed page together with the raw `Link` header and the page's own URL (for resolving\n * a relative `rel=\"next\"` target). Error-mode-agnostic like all iteration: a failed\n * page throws `ApiError` even on result-mode clients.\n */\nfunction linkPageCall(config: ClientConfig, op: OperationDescriptor, caps: Capabilities) {\n return async (args: OperationArgs = {}, init: RequestOptions = {}) => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const { parseAs, envelope: _envelope, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n const { response } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (!response.ok) {\n throw new ApiError(\n prepared.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n }\n return {\n page: await parse(response, readKind),\n linkHeader: response.headers.get('link'),\n // Some `Response` implementations leave `url` empty (mocks, constructed responses).\n url: response.url === '' ? prepared.url : response.url,\n };\n };\n}\n\n/**\n * Build a typed instance client over operation descriptors: one real bound method per\n * operation (attached by a construction-time loop — no Proxy), plus the core members\n * (`configure`/`use`/`auth`), which are assigned AFTER the loop so they win any name\n * collision with an operation. All behavior dispatches through the capability seam.\n */\nexport function createClientCore<\n Ops extends OpsShape,\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n>(\n operations: Record,\n initial: ClientConfig> = {},\n caps: Capabilities = {}\n): Client> {\n // The literal-union narrowing is a compile-time DX contract only; internally the\n // runtime works with the base (string-typed) context. One cast at this boundary —\n // `ClientConfig` is not assignable to `ClientConfig` (middleware ctx\n // params are contravariant).\n const given = initial as ClientConfig;\n // Private mutable config; the middleware array is copied so `use()` never mutates the caller's.\n const config: ClientConfig = { ...given, middleware: [...(given.middleware ?? [])] };\n const client = {} as Record;\n\n for (const [name, op] of Object.entries(operations)) {\n if (op.responseKind === 'sse') {\n const method = (args: OperationArgs = {}, init: SseOptions = {}) => {\n if (!caps.sse) {\n throw new Error(`SSE capability not wired: cannot stream operation \"${op.id}\"`);\n }\n const stream = caps.sse;\n return (async function* () {\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // A thunk the stream re-runs on every (re)connect, so auth (which `prepareRequest`\n // resolves) is refreshed per attempt rather than frozen at the first connect.\n const prepare = async () => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n return { url: prepared.url, init: prepared.init as SseOptions, body: prepared.body };\n };\n yield* stream(config, opCtx, prepare, op.sseDataKind ?? 'text');\n })();\n };\n // Consumers key off the function reference (cache keys, `OPERATIONS[fn.name]`), so\n // each closure carries its operationId instead of an inferred binding name.\n // `operationId` is the explicit, minification-proof form of the same identity\n // (the SPEC operationId — `name` is the emitted key, which a collision may rename).\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n client[name] = method;\n } else {\n const method = (args: OperationArgs = {}, init: RequestOptions = {}) =>\n execute(config, op, args, init, caps);\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n const spec = op.pagination;\n // Paginated ops keep their one-shot call and gain `.pages`/`.items`, dispatching\n // through the capability seam (like SSE: absent capability throws descriptively).\n // Iteration is error-mode-agnostic: the iterators' pointers are data-rooted, so on\n // a result-mode client (`errorMode` is fixed at construction — `configure()`\n // ignores it) each page's envelope is unwrapped before it reaches the capability.\n // A failed page aborts iteration by throwing ApiError, even on result-mode\n // clients; the `onError` middleware hook (throw-mode-only) is not invoked.\n client[name] =\n spec === undefined\n ? method\n : spec.style === 'link'\n ? Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pagesByLink(\n linkPageCall(config, op, caps),\n args,\n init\n ),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).itemsByLink(\n linkPageCall(config, op, caps),\n spec,\n args,\n init\n ),\n })\n : Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pages(pageCall(method, config), spec, args, init),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).items(pageCall(method, config), spec, args, init),\n });\n }\n }\n\n // Core members are assigned AFTER the operation loop — they win over colliding op names.\n client.configure = (next: ClientConfig): void => {\n // `errorMode` is fixed at generate time (it shapes the static types); flipping it at\n // runtime would silently desync return shapes from `Client`, so it is ignored.\n const { errorMode: _fixed, auth, ...rest } = next;\n Object.assign(config, rest);\n // `auth` merges into existing credentials (like the `auth.*` setters) rather than\n // replacing wholesale — so `configure({ auth: { bearer } })` keeps a previously set\n // basic/apiKey. `apiKey` merges per scheme.\n if (auth) {\n config.auth = {\n ...config.auth,\n ...auth,\n ...(auth.apiKey ? { apiKey: { ...config.auth?.apiKey, ...auth.apiKey } } : {}),\n };\n }\n };\n client.use = (...middleware: Middleware[]): void => {\n // Reassign (don't push) so a caller-provided `middleware` array isn't mutated.\n config.middleware = [...(config.middleware ?? []), ...middleware];\n };\n client.auth = {\n bearer(token: TokenProvider): void {\n config.auth = { ...config.auth, bearer: token };\n },\n basic(username: string, password: string): void {\n config.auth = { ...config.auth, basic: { username, password } };\n },\n apiKey(scheme: string, value: TokenProvider): void {\n config.auth = { ...config.auth, apiKey: { ...config.auth?.apiKey, [scheme]: value } };\n },\n };\n\n return client as Client>;\n}\n", 'paginate.ts': "import type { OperationArgs } from './create-client.js';\nimport type { PaginationSpec, QueryValue, RequestOptions } from './types.js';\n\n/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `params` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nexport function resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `params[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nexport async function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.params?.[spec.param];\n while (true) {\n const params = { ...args.params };\n if (cursor !== undefined) params[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, params }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `params[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.params?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call(\n { ...args, params: { ...args.params, [spec.param]: position } },\n init\n );\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nexport async function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\nexport type LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nexport function linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nexport async function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let params = args.params;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, params }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n params = { ...args.params, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nexport async function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n", + 'cli.ts': + "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. */\n positionals: Array<{ name: string; description?: string }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n binName: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const groups = new Set(commands.filter((c) => c.group).map((c) => c.group as string));\n let command: CliCommand | undefined;\n let rest: string[];\n if (groups.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n command = commands.find((c) => c.group === undefined && c.name === argv[0]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]}` };\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return { kind: 'usage-error', message: `${command.name} is not paginated; --page-all only applies to paginated operations` };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nfunction envPrefix(binName: string): string {\n return binName.replace(/[^A-Za-z0-9]+/g, '_').replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = { ...(auth.apiKey as Record | undefined), [scheme.key]: value };\n }\n }\n }\n return auth;\n}\n\nfunction renderHelp(commands: CliCommand[], binName: string, topic?: CliCommand | string): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [command.group] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(` --${flag.name} <${flag.type}>${choices}${required} ${flag.description ?? ''}`.trimEnd());\n }\n }\n return lines;\n }\n const scope = typeof topic === 'string' ? commands.filter((c) => c.group === topic) : commands;\n const lines = typeof topic === 'string' ? [`Usage: ${binName} ${topic} …`, '', 'Commands:'] : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n if (seenGroups.has(command.group)) continue;\n seenGroups.add(command.group);\n lines.push(` ${command.group} `);\n continue;\n }\n lines.push(` ${[command.group, command.name].filter(Boolean).join(' ')} ${command.summary ?? ''}`.trimEnd());\n }\n lines.push('', `Run ${binName} --help for command details; ${binName} schema prints its schemas.`);\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(\n headers: Record,\n secrets: string[]\n): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: CliCommand[],\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(commands, wiring.binName, invocation.topic)) stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(invocation.command.schemas ?? {}, null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n if (command.blob && globals.output === undefined) {\n return fail(4, { message: `${command.name} downloads a file: pass --output `, operationId: command.name });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, { message: `Invalid --json body: ${(error as Error).message}`, operationId: command.name });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (url: string, init: { method?: string; headers?: Record; body?: unknown }) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n try {\n if (globals.pageAll && !globals.dryRun) {\n const pages = (wiring.client[command.name] as { pages: (variables?: unknown) => AsyncIterable }).pages;\n for await (const page of pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = wiring.client[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", } as const; export type RuntimeModuleName = keyof typeof RUNTIME_SOURCES; @@ -35,10 +37,17 @@ export const RUNTIME_DECLARED_NAMES = [ 'ApiErrorLike', 'AuthCredentials', 'Capabilities', + 'CliAuthScheme', + 'CliCommand', + 'CliFlag', + 'CliGlobals', + 'CliInvocation', + 'CliWiring', 'Client', 'ClientConfig', 'ClientCore', 'FRAME_DELIMITER', + 'GLOBAL_FLAGS', 'IDEMPOTENT_METHODS', 'LinkPageCall', 'Middleware', @@ -76,6 +85,7 @@ export const RUNTIME_DECLARED_NAMES = [ 'defaultRetryOn', 'encodeBase64', 'encodeReserved', + 'envPrefix', 'execute', 'isConfigured', 'items', @@ -83,6 +93,7 @@ export const RUNTIME_DECLARED_NAMES = [ 'kindFor', 'linkNext', 'linkPageCall', + 'loadBody', 'mergeSetup', 'middlewareChain', 'pageCall', @@ -90,14 +101,18 @@ export const RUNTIME_DECLARED_NAMES = [ 'pagesByLink', 'paginateCapability', 'parse', + 'parseInvocation', 'parseSseFrame', 'prepareRequest', 'queryStyles', 'readError', + 'redactHeaders', + 'renderHelp', 'resolveAuth', 'resolvePointer', 'resolveToken', 'retryDelay', + 'runCli', 'send', 'sleep', 'splitArgs', diff --git a/packages/client-generator/src/index.ts b/packages/client-generator/src/index.ts index 6dee0fc702..3060b3dd4e 100644 --- a/packages/client-generator/src/index.ts +++ b/packages/client-generator/src/index.ts @@ -50,6 +50,9 @@ export type { SseOptions, TokenProvider, } from './runtime/index.js'; +// The generated-CLI engine (package-mode cli files import it from the package root). +export { runCli } from './runtime/cli.js'; +export type { CliAuthScheme, CliCommand, CliWiring } from './runtime/cli.js'; // The user-facing pagination rule shapes (`Config.pagination` / `x-redocly-pagination`). export type { PaginationConfig, PaginationRule, PaginationStyle } from './emitters/pagination.js'; export type { diff --git a/packages/client-generator/src/runtime/__tests__/cli.test.ts b/packages/client-generator/src/runtime/__tests__/cli.test.ts new file mode 100644 index 0000000000..060e50c778 --- /dev/null +++ b/packages/client-generator/src/runtime/__tests__/cli.test.ts @@ -0,0 +1,338 @@ +import { parseInvocation, runCli, type CliCommand, type CliWiring } from '../cli.js'; + +const LIST: CliCommand = { + group: 'orders', + name: 'listOrders', + summary: 'List orders.', + method: 'GET', + path: '/orders', + positionals: [], + flags: [ + { name: 'status', param: 'status', type: 'string', required: false, enum: ['open', 'closed'] }, + { name: 'limit', param: 'limit', type: 'number', required: false }, + { name: 'tag', param: 'tag', type: 'array', required: false }, + ], + paginated: true, +}; +const GET: CliCommand = { + group: 'orders', + name: 'getOrder', + method: 'GET', + path: '/orders/{orderId}', + positionals: [{ name: 'orderId' }], + flags: [], +}; +const CREATE: CliCommand = { + group: 'orders', + name: 'createOrder', + method: 'POST', + path: '/orders', + positionals: [], + flags: [], + body: { required: true }, + schemas: { request: { kind: 'object' } }, +}; +const PING: CliCommand = { name: 'ping', method: 'GET', path: '/ping', positionals: [], flags: [] }; +const COMMANDS = [LIST, GET, CREATE, PING]; + +describe('parseInvocation', () => { + it('routes group + name, coerces flag types, repeats arrays, accepts --flag=value', () => { + const parsed = parseInvocation(COMMANDS, [ + 'orders', + 'listOrders', + '--status', + 'open', + '--limit=10', + '--tag', + 'a', + '--tag', + 'b', + ]); + expect(parsed).toMatchObject({ + kind: 'run', + command: LIST, + params: { status: 'open', limit: 10, tag: ['a', 'b'] }, + }); + }); + + it('binds positionals in path order and routes untagged commands flat', () => { + expect(parseInvocation(COMMANDS, ['orders', 'getOrder', 'ord_1'])).toMatchObject({ + kind: 'run', + positionals: { orderId: 'ord_1' }, + }); + expect(parseInvocation(COMMANDS, ['ping'])).toMatchObject({ kind: 'run', command: PING }); + }); + + it('extracts global flags and leaves the body source raw', () => { + const parsed = parseInvocation(COMMANDS, [ + 'orders', + 'createOrder', + '--json', + '{"a":1}', + '--dry-run', + '--server-url', + 'http://x', + '--format', + 'ndjson', + ]); + expect(parsed).toMatchObject({ + kind: 'run', + globals: { json: '{"a":1}', dryRun: true, serverUrl: 'http://x', format: 'ndjson' }, + }); + }); + + it.each([ + [['nowhere'], /unknown command/i], + [['orders', 'nowhere'], /unknown command/i], + [['orders', 'listOrders', '--bogus', 'x'], /unknown flag/i], + [['orders', 'listOrders', '--limit', 'ten'], /expects a number/i], + [['orders', 'listOrders', '--status', 'stale'], /one of: open, closed/i], + [['orders', 'getOrder'], /missing required argument/i], + [['orders', 'getOrder', 'a', 'b'], /unexpected argument/i], + [['orders', 'getOrder', 'a', '--json', '{}'], /does not accept a request body/i], + [['orders', 'createOrder'], /requires a request body/i], + [['orders', 'listOrders', '--format', 'xml'], /one of: json, ndjson/i], + ])('usage error for %j', (argv, message) => { + expect(parseInvocation(COMMANDS, argv as string[])).toMatchObject({ + kind: 'usage-error', + message: expect.stringMatching(message), + }); + }); + + it('recognizes help at root, group, and command level, and the schema pseudo-command', () => { + expect(parseInvocation(COMMANDS, [])).toMatchObject({ kind: 'help' }); + expect(parseInvocation(COMMANDS, ['--help'])).toMatchObject({ kind: 'help' }); + expect(parseInvocation(COMMANDS, ['orders', '--help'])).toMatchObject({ + kind: 'help', + topic: 'orders', + }); + expect(parseInvocation(COMMANDS, ['orders', 'listOrders', '--help'])).toMatchObject({ + kind: 'help', + topic: LIST, + }); + expect(parseInvocation(COMMANDS, ['schema', 'createOrder'])).toMatchObject({ + kind: 'schema', + command: CREATE, + }); + expect(parseInvocation(COMMANDS, ['schema', 'nowhere'])).toMatchObject({ + kind: 'usage-error', + }); + }); +}); + +type FakeCall = { name: string; variables: unknown }; + +function fakeWiring(overrides: Partial & { results?: Record } = {}) { + const calls: FakeCall[] = []; + const configured: Record[] = []; + const out: string[] = []; + const err: string[] = []; + const { results = {}, ...rest } = overrides; + const client: Record = {}; + for (const command of COMMANDS) { + const method = async (variables: unknown) => { + calls.push({ name: command.name, variables }); + const result = results[command.name]; + if (result instanceof Error) throw result; + return result; + }; + client[command.name] = Object.assign(method, { + pages: async function* (variables: unknown) { + calls.push({ name: `${command.name}.pages`, variables }); + yield { items: [1] }; + yield { items: [2] }; + }, + }); + } + const wiring: CliWiring = { + binName: 'cafe', + client, + configure: (config) => configured.push(config as Record), + schemes: [{ key: 'BearerAuth', kind: 'bearer' }], + env: {}, + stdout: (line) => out.push(line), + stderr: (line) => err.push(line), + ...rest, + }; + return { wiring, calls, configured, out, err }; +} + +describe('runCli', () => { + it('dispatches grouped args and pretty-prints the JSON result', async () => { + const { wiring, calls, out } = fakeWiring({ results: { getOrder: { id: 'ord_1' } } }); + const code = await runCli(COMMANDS, wiring, ['orders', 'getOrder', 'ord_1']); + expect(code).toBe(0); + expect(calls).toEqual([{ name: 'getOrder', variables: { orderId: 'ord_1' } }]); + expect(JSON.parse(out.join('\n'))).toEqual({ id: 'ord_1' }); + }); + + it('passes query params under `params` and prints nothing for void results', async () => { + const { wiring, calls, out } = fakeWiring(); + const code = await runCli(COMMANDS, wiring, ['orders', 'listOrders', '--status', 'open']); + expect(code).toBe(0); + expect(calls[0]).toEqual({ name: 'listOrders', variables: { params: { status: 'open' } } }); + expect(out).toEqual([]); + }); + + it('loads --json bodies inline, from @file, and from @- (stdin)', async () => { + const { wiring, calls } = fakeWiring({ + readFile: () => '{"from":"file"}', + stdin: () => '{"from":"stdin"}', + }); + await runCli(COMMANDS, wiring, ['orders', 'createOrder', '--json', '{"from":"inline"}']); + await runCli(COMMANDS, wiring, ['orders', 'createOrder', '--json', '@body.json']); + await runCli(COMMANDS, wiring, ['orders', 'createOrder', '--json', '@-']); + expect(calls.map((call) => (call.variables as { body: unknown }).body)).toEqual([ + { from: 'inline' }, + { from: 'file' }, + { from: 'stdin' }, + ]); + }); + + it('malformed --json is a usage error: JSON error object on stderr, exit 4, no dispatch', async () => { + const { wiring, calls, err } = fakeWiring(); + const code = await runCli(COMMANDS, wiring, ['orders', 'createOrder', '--json', '{nope']); + expect(code).toBe(4); + expect(calls).toEqual([]); + expect(JSON.parse(err.join('\n')).error.code).toBe(4); + }); + + it.each([ + [Object.assign(new Error('boom'), { name: 'ApiError', status: 500 }), 1], + [Object.assign(new Error('nope'), { name: 'ApiError', status: 401 }), 2], + [Object.assign(new Error('bad'), { name: 'ZodValidationError' }), 3], + [new Error('plain'), 1], + ])('maps thrown %o to exit %i with a JSON error on stderr', async (error, expected) => { + const { wiring, err } = fakeWiring({ results: { ping: error } }); + const code = await runCli(COMMANDS, wiring, ['ping']); + expect(code).toBe(expected); + const printed = JSON.parse(err.join('\n')).error; + expect(printed.code).toBe(expected); + expect(printed.message).toBe(error.message); + }); + + it('resolves bearer auth from _TOKEN; --token wins over env', async () => { + const { wiring, configured } = fakeWiring({ env: { CAFE_TOKEN: 'from-env' } }); + await runCli(COMMANDS, wiring, ['ping']); + expect(configured[0]).toEqual({ auth: { bearer: 'from-env' } }); + + const flagged = fakeWiring({ env: { CAFE_TOKEN: 'from-env' } }); + await runCli(COMMANDS, flagged.wiring, ['ping', '--token', 'from-flag']); + expect(flagged.configured[0]).toEqual({ auth: { bearer: 'from-flag' } }); + }); + + it('resolves basic and apiKey credentials from prefixed env vars', async () => { + const { wiring, configured } = fakeWiring({ + schemes: [ + { key: 'BasicAuth', kind: 'basic' }, + { key: 'ApiKeyAuth', kind: 'apiKey' }, + ], + env: { CAFE_USERNAME: 'u', CAFE_PASSWORD: 'p', CAFE_API_KEY_API_KEY_AUTH: 'k' }, + }); + await runCli(COMMANDS, wiring, ['ping']); + expect(configured[0]).toEqual({ + auth: { basic: { username: 'u', password: 'p' }, apiKey: { ApiKeyAuth: 'k' } }, + }); + }); + + it('--server-url reconfigures the client', async () => { + const { wiring, configured } = fakeWiring(); + await runCli(COMMANDS, wiring, ['ping', '--server-url', 'http://other']); + expect(configured).toContainEqual({ serverUrl: 'http://other' }); + }); + + it('--dry-run captures the prepared request via injected fetch, redacts credentials, sends nothing', async () => { + const { wiring, configured, out } = fakeWiring({ env: { CAFE_TOKEN: 'secret-token' } }); + // The generated client would call the injected fetch; emulate that with a client + // whose method invokes whatever fetch was configured, like the real runtime does. + let injectedFetch: ((url: string, init: RequestInit) => Promise) | undefined; + wiring.configure = (config) => { + configured.push(config as Record); + const candidate = (config as { fetch?: typeof injectedFetch }).fetch; + if (candidate) injectedFetch = candidate; + }; + (wiring.client as Record).ping = async () => { + await injectedFetch?.('http://api/ping', { + method: 'GET', + headers: { Authorization: 'Bearer secret-token' }, + }); + return { ok: true }; + }; + const code = await runCli(COMMANDS, wiring, ['ping', '--dry-run']); + expect(code).toBe(0); + const printed = JSON.parse(out.join('\n')); + expect(printed).toEqual({ + url: 'http://api/ping', + method: 'GET', + headers: { Authorization: '***' }, + }); + }); + + it('--page-all streams one JSON page per line through .pages()', async () => { + const { wiring, out } = fakeWiring(); + const code = await runCli(COMMANDS, wiring, ['orders', 'listOrders', '--page-all']); + expect(code).toBe(0); + expect(out.map((line) => JSON.parse(line))).toEqual([{ items: [1] }, { items: [2] }]); + }); + + it('--page-all on a non-paginated operation is a usage error', async () => { + const { wiring } = fakeWiring(); + expect(await runCli(COMMANDS, wiring, ['ping', '--page-all'])).toBe(4); + }); + + it('sse results stream as NDJSON events', async () => { + const events = [ + { event: 'tick', data: 1 }, + { event: 'tick', data: 2 }, + ]; + const sseCommands = [{ ...PING, name: 'streamEvents', sse: true }]; + const { wiring, out } = fakeWiring(); + (wiring.client as Record).streamEvents = async function* () { + yield* events; + }; + const code = await runCli(sseCommands, wiring, ['streamEvents']); + expect(code).toBe(0); + expect(out.map((line) => JSON.parse(line))).toEqual(events); + }); + + it('blob results require --output and print a byte receipt', async () => { + const blobCommands = [{ ...PING, name: 'downloadReport', blob: true }]; + const writes: Array<{ path: string; bytes: number }> = []; + const { wiring, out } = fakeWiring({ + writeFile: (path, data) => writes.push({ path, bytes: data.length }), + }); + (wiring.client as Record).downloadReport = async () => + new Blob([new Uint8Array([1, 2, 3])]); + expect(await runCli(blobCommands, wiring, ['downloadReport'])).toBe(4); + const code = await runCli(blobCommands, wiring, ['downloadReport', '--output', 'report.bin']); + expect(code).toBe(0); + expect(writes).toEqual([{ path: 'report.bin', bytes: 3 }]); + expect(JSON.parse(out.join('\n'))).toEqual({ saved: 'report.bin', bytes: 3 }); + }); + + it('schema prints the stored request/response schemas', async () => { + const { wiring, out } = fakeWiring(); + const code = await runCli(COMMANDS, wiring, ['schema', 'createOrder']); + expect(code).toBe(0); + expect(JSON.parse(out.join('\n'))).toEqual({ request: { kind: 'object' } }); + }); + + it('help renders groups at the root, commands per group, and flags per command', async () => { + const root = fakeWiring(); + expect(await runCli(COMMANDS, root.wiring, ['--help'])).toBe(0); + const rootText = root.out.join('\n'); + expect(rootText).toContain('orders'); + expect(rootText).toContain('ping'); + + const group = fakeWiring(); + await runCli(COMMANDS, group.wiring, ['orders', '--help']); + expect(group.out.join('\n')).toContain('listOrders'); + + const command = fakeWiring(); + await runCli(COMMANDS, command.wiring, ['orders', 'listOrders', '--help']); + const commandText = command.out.join('\n'); + expect(commandText).toContain('--status'); + expect(commandText).toContain('open, closed'); + expect(commandText).toContain('List orders.'); + }); +}); diff --git a/packages/client-generator/src/runtime/cli.ts b/packages/client-generator/src/runtime/cli.ts new file mode 100644 index 0000000000..1f7073c45d --- /dev/null +++ b/packages/client-generator/src/runtime/cli.ts @@ -0,0 +1,429 @@ +// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the +// instance client and maps outcomes to the documented exit-code contract +// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature, +// but every effect (env, stdin, files, output) is injected through the wiring so +// the module itself stays dependency-free and fully unit-testable; the emitted +// entry fills the defaults with real `node:fs`/`process` bindings. + +/** One flag derived from a query parameter. */ +export type CliFlag = { + /** Kebab-cased flag name (`--page-size`). */ + name: string; + /** Original wire parameter name. */ + param: string; + type: 'string' | 'number' | 'boolean' | 'array'; + required: boolean; + enum?: string[]; + description?: string; +}; + +/** One executable command, derived from the IR at generate time. Pure data. */ +export type CliCommand = { + /** Tag; absent = flat/untagged. */ + group?: string; + name: string; + summary?: string; + method: string; + path: string; + /** Path params, in path-template order. */ + positionals: Array<{ name: string; description?: string }>; + flags: CliFlag[]; + /** Present when the operation takes a JSON request body. */ + body?: { required: boolean }; + paginated?: boolean; + 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 = { + binName: string; + /** The generated instance client (grouped-args methods). */ + client: Record; + 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; +}; + +type CliGlobals = { + serverUrl?: string; + format?: 'json' | 'ndjson'; + dryRun?: boolean; + pageAll?: boolean; + output?: string; + token?: string; + json?: string; +}; + +export type CliInvocation = + | { kind: 'help'; topic?: CliCommand | string } + | { kind: 'schema'; command: CliCommand } + | { + kind: 'run'; + command: CliCommand; + positionals: Record; + params: Record; + globals: CliGlobals; + } + | { kind: 'usage-error'; message: string }; + +const GLOBAL_FLAGS: Record = { + 'server-url': { key: 'serverUrl' }, + format: { key: 'format' }, + 'dry-run': { key: 'dryRun', boolean: true }, + 'page-all': { key: 'pageAll', boolean: true }, + output: { key: 'output' }, + token: { key: 'token' }, + json: { key: 'json' }, +}; + +/** Resolve argv against the command table. Pure — no I/O, no env. */ +export function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation { + if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' }; + + if (argv[0] === 'schema') { + const command = commands.find((candidate) => candidate.name === argv[1]); + return command + ? { kind: 'schema', command } + : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() }; + } + + const groups = new Set(commands.filter((c) => c.group).map((c) => c.group as string)); + let command: CliCommand | undefined; + let rest: string[]; + if (groups.has(argv[0])) { + if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] }; + command = commands.find((c) => c.group === argv[0] && c.name === argv[1]); + if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` }; + rest = argv.slice(2); + } else { + command = commands.find((c) => c.group === undefined && c.name === argv[0]); + if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]}` }; + rest = argv.slice(1); + } + if (rest.includes('--help')) return { kind: 'help', topic: command }; + + const positionals: Record = {}; + const params: Record = {}; + const globals: CliGlobals = {}; + let positionalIndex = 0; + for (let index = 0; index < rest.length; index++) { + const token = rest[index]; + if (!token.startsWith('--')) { + const slot = command.positionals[positionalIndex++]; + if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` }; + positionals[slot.name] = token; + continue; + } + const equals = token.indexOf('='); + const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals); + const inlineValue = equals === -1 ? undefined : token.slice(equals + 1); + const takeValue = (): string | undefined => + inlineValue !== undefined ? inlineValue : rest[++index]; + + const global = GLOBAL_FLAGS[flagName]; + if (global) { + if (global.boolean) { + (globals[global.key] as boolean) = true; + continue; + } + const value = takeValue(); + if (value === undefined) { + return { kind: 'usage-error', message: `Flag --${flagName} expects a value` }; + } + if (global.key === 'format' && value !== 'json' && value !== 'ndjson') { + return { kind: 'usage-error', message: `--format must be one of: json, ndjson` }; + } + (globals[global.key] as string) = value; + continue; + } + + const flag = command.flags.find((candidate) => candidate.name === flagName); + if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` }; + if (flag.type === 'boolean') { + params[flag.param] = true; + continue; + } + const value = takeValue(); + if (value === undefined) { + return { kind: 'usage-error', message: `Flag --${flagName} expects a value` }; + } + if (flag.enum && !flag.enum.includes(value)) { + return { + kind: 'usage-error', + message: `--${flagName} must be one of: ${flag.enum.join(', ')}`, + }; + } + if (flag.type === 'number') { + const numeric = Number(value); + if (Number.isNaN(numeric)) { + return { kind: 'usage-error', message: `--${flagName} expects a number, got "${value}"` }; + } + params[flag.param] = numeric; + } else if (flag.type === 'array') { + const existing = params[flag.param]; + params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value]; + } else { + params[flag.param] = value; + } + } + + for (const slot of command.positionals) { + if (!(slot.name in positionals)) { + return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` }; + } + } + for (const flag of command.flags) { + if (flag.required && !(flag.param in params)) { + return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` }; + } + } + if (globals.json !== undefined && !command.body) { + return { kind: 'usage-error', message: `${command.name} does not accept a request body` }; + } + if (command.body?.required && globals.json === undefined) { + return { + kind: 'usage-error', + message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`, + }; + } + if (globals.pageAll && !command.paginated) { + return { + kind: 'usage-error', + message: `${command.name} is not paginated; --page-all only applies to paginated operations`, + }; + } + return { kind: 'run', command, positionals, params, globals }; +} + +/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */ +function envPrefix(binName: string): string { + return binName + .replace(/[^A-Za-z0-9]+/g, '_') + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .toUpperCase(); +} + +function resolveAuth(wiring: CliWiring, token: string | undefined): Record { + const env = wiring.env ?? {}; + const prefix = envPrefix(wiring.binName); + const auth: Record = {}; + for (const scheme of wiring.schemes ?? []) { + if (scheme.kind === 'bearer') { + const value = token ?? env[`${prefix}_TOKEN`]; + if (value !== undefined) auth.bearer = value; + } else if (scheme.kind === 'basic') { + const username = env[`${prefix}_USERNAME`]; + const password = env[`${prefix}_PASSWORD`]; + if (username !== undefined && password !== undefined) auth.basic = { username, password }; + } else { + const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`]; + if (value !== undefined) { + auth.apiKey = { + ...(auth.apiKey as Record | undefined), + [scheme.key]: value, + }; + } + } + } + return auth; +} + +function renderHelp( + commands: CliCommand[], + binName: string, + topic?: CliCommand | string +): string[] { + if (topic !== undefined && typeof topic !== 'string') { + const command = topic; + const usage = [ + binName, + ...(command.group ? [command.group] : []), + command.name, + ...command.positionals.map((slot) => `<${slot.name}>`), + ...(command.flags.length > 0 ? ['[flags]'] : []), + ...(command.body ? ["--json '' | @file | @-"] : []), + ].join(' '); + const lines = [`Usage: ${usage}`]; + if (command.summary) lines.push('', command.summary); + if (command.flags.length > 0) { + lines.push('', 'Flags:'); + for (const flag of command.flags) { + const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : ''; + const required = flag.required ? ' [required]' : ''; + lines.push( + ` --${flag.name} <${flag.type}>${choices}${required} ${flag.description ?? ''}`.trimEnd() + ); + } + } + return lines; + } + const scope = typeof topic === 'string' ? commands.filter((c) => c.group === topic) : commands; + const lines = + typeof topic === 'string' + ? [`Usage: ${binName} ${topic} …`, '', 'Commands:'] + : [`Usage: ${binName} [group] …`, '', 'Commands:']; + const seenGroups = new Set(); + for (const command of scope) { + if (typeof topic !== 'string' && command.group) { + if (seenGroups.has(command.group)) continue; + seenGroups.add(command.group); + lines.push(` ${command.group} `); + continue; + } + lines.push( + ` ${[command.group, command.name].filter(Boolean).join(' ')} ${command.summary ?? ''}`.trimEnd() + ); + } + lines.push( + '', + `Run ${binName} --help for command details; ${binName} schema prints its schemas.` + ); + return lines; +} + +function loadBody(source: string, wiring: CliWiring): unknown { + const raw = + source === '@-' + ? (wiring.stdin ?? (() => ''))() + : source.startsWith('@') + ? (wiring.readFile ?? (() => ''))(source.slice(1)) + : source; + return JSON.parse(raw); +} + +/** Replace header values containing a known credential with `***`. */ +function redactHeaders(headers: Record, secrets: string[]): Record { + const redacted: Record = {}; + for (const [name, value] of Object.entries(headers)) { + redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret)) + ? '***' + : value; + } + return redacted; +} + +/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */ +export async function runCli( + commands: CliCommand[], + wiring: CliWiring, + argv: string[] +): Promise { + const { stdout, stderr } = wiring; + const fail = (code: number, error: Record): number => { + stderr(JSON.stringify({ error: { code, ...error } })); + return code; + }; + + const invocation = parseInvocation(commands, argv); + if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message }); + if (invocation.kind === 'help') { + for (const line of renderHelp(commands, wiring.binName, invocation.topic)) stdout(line); + return 0; + } + if (invocation.kind === 'schema') { + stdout(JSON.stringify(invocation.command.schemas ?? {}, null, 2)); + return 0; + } + + const { command, positionals, params, globals } = invocation; + if (command.blob && globals.output === undefined) { + return fail(4, { + message: `${command.name} downloads a file: pass --output `, + operationId: command.name, + }); + } + let body: unknown; + if (globals.json !== undefined) { + try { + body = loadBody(globals.json, wiring); + } catch (error) { + return fail(4, { + message: `Invalid --json body: ${(error as Error).message}`, + operationId: command.name, + }); + } + } + + const auth = resolveAuth(wiring, globals.token); + if (Object.keys(auth).length > 0) wiring.configure({ auth }); + if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl }); + + const secrets = [ + ...(typeof auth.bearer === 'string' ? [auth.bearer] : []), + ...(auth.basic ? [(auth.basic as { password: string }).password] : []), + ...Object.values((auth.apiKey as Record | undefined) ?? {}), + ]; + let captured: Record | undefined; + if (globals.dryRun) { + wiring.configure({ + fetch: async ( + url: string, + init: { method?: string; headers?: Record; body?: unknown } + ) => { + captured = { + url, + method: init.method, + headers: redactHeaders(init.headers ?? {}, secrets), + ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}), + }; + return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }); + }, + }); + } + + const variables: Record = { ...positionals }; + if (Object.keys(params).length > 0) variables.params = params; + if (body !== undefined) variables.body = body; + const argument = Object.keys(variables).length > 0 ? variables : undefined; + + try { + if (globals.pageAll && !globals.dryRun) { + const pages = ( + wiring.client[command.name] as { pages: (variables?: unknown) => AsyncIterable } + ).pages; + for await (const page of pages(argument)) stdout(JSON.stringify(page)); + return 0; + } + const method = wiring.client[command.name] as (variables?: unknown) => Promise; + const result = await method(argument); + if (globals.dryRun) { + stdout(JSON.stringify(captured, null, 2)); + return 0; + } + if (command.sse) { + for await (const event of result as AsyncIterable) stdout(JSON.stringify(event)); + return 0; + } + if (command.blob) { + const bytes = new Uint8Array(await (result as Blob).arrayBuffer()); + (wiring.writeFile ?? (() => {}))(globals.output as string, bytes); + stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length })); + return 0; + } + if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2)); + return 0; + } catch (error) { + const thrown = error as Error & { status?: number; issues?: unknown }; + const detail = { + message: thrown.message, + operationId: command.name, + ...(thrown.status !== undefined ? { status: thrown.status } : {}), + ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}), + }; + if (thrown.name === 'ZodValidationError') return fail(3, detail); + if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) { + return fail(2, detail); + } + return fail(1, detail); + } +} From 47d9a41bf1eabdc56f6f0ec03f44388b6e795302 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 17:56:00 +0300 Subject: [PATCH 029/211] =?UTF-8?q?feat(client-generator):=20cli=20generat?= =?UTF-8?q?or=20=E2=80=94=20command=20data=20emitter=20and=20registration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/emitters/__tests__/cli.test.ts | 245 ++++++++++++++++++ packages/client-generator/src/emitters/cli.ts | 163 ++++++++++++ .../src/emitters/inline-runtime.ts | 5 + .../src/emitters/runtime-sources.ts | 2 +- .../src/generators/__tests__/cli.test.ts | 85 ++++++ .../client-generator/src/generators/cli.ts | 46 ++++ .../client-generator/src/generators/index.ts | 2 + .../client-generator/src/generators/meta.ts | 7 + .../client-generator/src/generators/types.ts | 3 + packages/client-generator/src/pipeline.ts | 1 + packages/client-generator/src/runtime/cli.ts | 13 +- 11 files changed, 566 insertions(+), 6 deletions(-) create mode 100644 packages/client-generator/src/emitters/__tests__/cli.test.ts create mode 100644 packages/client-generator/src/emitters/cli.ts create mode 100644 packages/client-generator/src/generators/__tests__/cli.test.ts create mode 100644 packages/client-generator/src/generators/cli.ts diff --git a/packages/client-generator/src/emitters/__tests__/cli.test.ts b/packages/client-generator/src/emitters/__tests__/cli.test.ts new file mode 100644 index 0000000000..1bc6644f00 --- /dev/null +++ b/packages/client-generator/src/emitters/__tests__/cli.test.ts @@ -0,0 +1,245 @@ +import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js'; +import { commandData, renderCliModule } from '../cli.js'; + +const STRING: SchemaModel = { kind: 'scalar', scalar: 'string' }; +const INT: SchemaModel = { kind: 'scalar', scalar: 'integer' }; + +const MODEL: ApiModel = { + title: 'Cafe', + version: '1.0.0', + serverUrl: 'https://api.cafe.example', + services: [ + { + name: 'Orders', + operations: [ + { + name: 'listOrders', + specName: 'listOrders', + method: 'get', + path: '/orders', + summary: 'List orders.', + tags: ['Orders'], + pathParams: [], + queryParams: [ + { + name: 'status', + in: 'query', + required: false, + schema: { kind: 'enum', values: ['open', 'closed'], scalar: 'string' }, + }, + { name: 'pageSize', in: 'query', required: false, schema: INT }, + { + name: 'tag', + in: 'query', + required: false, + schema: { kind: 'array', items: STRING }, + }, + { name: 'cursor', in: 'query', required: false, schema: STRING }, + ], + headerParams: [], + cookieParams: [], + security: [], + paginationExtension: { + style: 'cursor', + cursorParam: 'cursor', + nextCursor: '/next', + items: '/items', + }, + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'ref', name: 'OrderPage' }, + }, + ], + errorResponses: [], + }, + { + name: 'getOrder', + specName: 'getOrder', + method: 'get', + path: '/orders/{orderId}', + tags: ['Orders'], + pathParams: [{ name: 'orderId', in: 'path', required: true, schema: STRING }], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'ref', name: 'Order' }, + }, + ], + errorResponses: [], + }, + { + name: 'createOrder', + specName: 'createOrder', + method: 'post', + path: '/orders', + tags: ['Orders'], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + requestBody: { + contentType: 'application/json', + required: true, + schema: { kind: 'ref', name: 'Order' }, + }, + successResponses: [ + { + status: '201', + contentType: 'application/json', + schema: { kind: 'ref', name: 'Order' }, + }, + ], + errorResponses: [], + }, + { + name: 'streamEvents', + specName: 'streamEvents', + method: 'get', + path: '/events', + tags: ['Orders'], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { + status: '200', + contentType: 'text/event-stream', + schema: { kind: 'object', properties: [] }, + }, + ], + errorResponses: [], + }, + { + name: 'downloadReport', + specName: 'downloadReport', + method: 'get', + path: '/report', + tags: ['Orders'], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { status: '200', contentType: 'application/octet-stream', schema: { kind: 'unknown' } }, + ], + errorResponses: [], + }, + ], + }, + { + name: 'Default', + operations: [ + { + name: 'ping', + specName: 'ping', + method: 'get', + path: '/ping', + tags: [], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [], + errorResponses: [], + }, + ], + }, + ], + schemas: [ + { + name: 'Order', + schema: { kind: 'object', properties: [{ name: 'id', schema: STRING, required: true }] }, + }, + { + name: 'OrderPage', + schema: { + kind: 'object', + properties: [ + { + name: 'items', + schema: { kind: 'array', items: { kind: 'ref', name: 'Order' } }, + required: true, + }, + { name: 'next', schema: STRING, required: false }, + ], + }, + }, + ], + securitySchemes: [{ key: 'BearerAuth', kind: 'bearer' }], +} as unknown as ApiModel; + +describe('commandData', () => { + it('derives groups from tags, flags from query params, and positionals in path order', () => { + const commands = commandData(MODEL, {}); + const list = commands.find((command) => command.name === 'listOrders'); + expect(list).toMatchObject({ + group: 'Orders', + summary: 'List orders.', + paginated: true, + flags: [ + { name: 'status', param: 'status', type: 'string', enum: ['open', 'closed'] }, + { name: 'page-size', param: 'pageSize', type: 'number' }, + { name: 'tag', param: 'tag', type: 'array' }, + { name: 'cursor', param: 'cursor', type: 'string' }, + ], + }); + expect(commands.find((command) => command.name === 'getOrder')).toMatchObject({ + positionals: [{ name: 'orderId' }], + }); + // Untagged operations are flat: no group. + expect(commands.find((command) => command.name === 'ping')?.group).toBeUndefined(); + }); + + it('marks bodies, SSE, and blob operations, and stores IR schemas verbatim', () => { + const commands = commandData(MODEL, {}); + expect(commands.find((command) => command.name === 'createOrder')).toMatchObject({ + body: { required: true }, + schemas: { + request: { kind: 'ref', name: 'Order' }, + response: { kind: 'ref', name: 'Order' }, + }, + }); + expect(commands.find((command) => command.name === 'streamEvents')?.sse).toBe(true); + expect(commands.find((command) => command.name === 'downloadReport')?.blob).toBe(true); + }); +}); + +describe('renderCliModule', () => { + const options = { + stem: 'client', + importExt: 'js', + runtime: 'inline' as const, + zodSelected: false, + binName: 'cafe', + }; + + it('emits a shebang entry that wires node bindings and embeds the cli runtime inline', () => { + const out = renderCliModule(MODEL, options); + expect(out.startsWith('#!/usr/bin/env node')).toBe(true); + expect(out).toContain('function parseInvocation'); // embedded runtime + expect(out).toContain('import { client, configure } from "./client.js";'); + expect(out).toContain('schemes: [{"key":"BearerAuth","kind":"bearer"}]'); + expect(out).toContain('await runCli(COMMANDS'); + 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 { runCli, type CliCommand } from "@redocly/client-generator";'); + expect(out).not.toContain('function parseInvocation'); + expect(out).toContain('import { zodValidation } from "./client.zod.js";'); + expect(out).toContain('use(zodValidation());'); + }); +}); diff --git a/packages/client-generator/src/emitters/cli.ts b/packages/client-generator/src/emitters/cli.ts new file mode 100644 index 0000000000..49a7f20d62 --- /dev/null +++ b/packages/client-generator/src/emitters/cli.ts @@ -0,0 +1,163 @@ +// The cli emitter: derives pure `CliCommand[]` data from the IR and renders +// `.cli.ts` — a shebang entry that embeds (inline) or imports (package) +// the `runCli` engine and dispatches through the sibling generated client. + +import { casing } from '../authoring/naming.js'; +import type { + ApiModel, + OperationModel, + ParamModel, + SchemaModel, +} from '../intermediate-representation/model.js'; +import type { CliAuthScheme, CliCommand, CliFlag } from '../runtime/cli.js'; +import { HEADER } from './emit-options.js'; +import { embedCliRuntime } from './inline-runtime.js'; +import { resolveOperationPagination, type PaginationConfig } from './pagination.js'; +import { isSseOp } from './sse.js'; + +function kebab(name: string): string { + return casing.snake(name).replace(/_/g, '-'); +} + +function flagFor(param: ParamModel): CliFlag { + const schema = param.schema; + const type: CliFlag['type'] = + schema.kind === 'array' + ? 'array' + : schema.kind === 'scalar' && (schema.scalar === 'integer' || schema.scalar === 'number') + ? 'number' + : schema.kind === 'scalar' && schema.scalar === 'boolean' + ? 'boolean' + : 'string'; + return { + name: kebab(param.name), + param: param.name, + type, + required: param.required, + ...(schema.kind === 'enum' ? { enum: schema.values.map(String) } : {}), + ...(param.description !== undefined ? { description: param.description } : {}), + }; +} + +/** Mirrors `computeResponse`: a blob operation has binary success content and no JSON alternative. */ +function isBlobOp(op: OperationModel): boolean { + const responses = op.successResponses; + if (responses.some((response) => response.contentType.toLowerCase().includes('json'))) { + return false; + } + return responses.some( + (response) => + response.contentType.startsWith('image/') || + response.contentType === 'application/octet-stream' + ); +} + +function jsonSuccessSchema(op: OperationModel): SchemaModel | undefined { + return op.successResponses.find((response) => response.contentType.toLowerCase().includes('json')) + ?.schema; +} + +/** Every operation as pure command data — the table `runCli` interprets. */ +export function commandData( + model: ApiModel, + emit: { pagination?: PaginationConfig } +): CliCommand[] { + const commands: CliCommand[] = []; + for (const service of model.services) { + for (const op of service.operations) { + const jsonBody = op.requestBody?.contentType.toLowerCase().includes('json') + ? op.requestBody + : undefined; + const responseSchema = jsonSuccessSchema(op); + commands.push({ + ...(service.name !== 'Default' ? { group: service.name } : {}), + name: op.name, + ...(op.summary !== undefined ? { summary: op.summary } : {}), + method: op.method.toUpperCase(), + path: op.path, + positionals: op.pathParams.map((param) => ({ + name: param.name, + ...(param.description !== undefined ? { description: param.description } : {}), + })), + flags: op.queryParams.map(flagFor), + ...(jsonBody ? { body: { required: jsonBody.required } } : {}), + ...(resolveOperationPagination(op, model, emit.pagination).spec !== undefined + ? { paginated: true } + : {}), + ...(isSseOp(op) ? { sse: true } : {}), + ...(isBlobOp(op) ? { blob: true } : {}), + ...(jsonBody !== undefined || responseSchema !== undefined + ? { + schemas: { + ...(jsonBody ? { request: jsonBody.schema } : {}), + ...(responseSchema !== undefined ? { response: responseSchema } : {}), + }, + } + : {}), + }); + } + } + return commands; +} + +/** JSON as a TS expression: U+2028/U+2029 are line terminators in code contexts. */ +function codeJson(value: unknown, indent?: number): string { + return JSON.stringify(value, null, indent) + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029'); +} + +export type CliModuleOptions = { + stem: string; + importExt: string; + runtime: 'inline' | 'package'; + zodSelected: boolean; + binName: string; + pagination?: PaginationConfig; +}; + +/** The whole `.cli.ts` file. */ +export function renderCliModule(model: ApiModel, options: CliModuleOptions): string { + const commands = commandData(model, { pagination: options.pagination }); + const schemes: CliAuthScheme[] = model.securitySchemes.map((scheme) => ({ + key: scheme.key, + kind: scheme.kind === 'bearer' || scheme.kind === 'basic' ? scheme.kind : 'apiKey', + })); + const clientModule = `./${options.stem}.${options.importExt}`; + const clientImports = ['client', 'configure', ...(options.zodSelected ? ['use'] : [])]; + + const parts = [ + '#!/usr/bin/env node', + HEADER, + 'import { readFileSync, writeFileSync } from "node:fs";', + [ + ...(options.runtime === 'package' + ? ['import { runCli, type CliCommand } 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()] + : []), + `const COMMANDS: CliCommand[] = ${codeJson(commands, 2)};`, + ...(options.zodSelected ? ['use(zodValidation());'] : []), + `process.exit( + await runCli(COMMANDS, { + binName: ${codeJson(options.binName)}, + client, + configure, + schemes: ${codeJson(schemes)}, + env: process.env, + stdin: () => readFileSync(0, "utf-8"), + readFile: (path: string) => readFileSync(path, "utf-8"), + writeFile: (path: string, data: Uint8Array) => writeFileSync(path, data), + stdout: (line: string) => console.log(line), + stderr: (line: string) => console.error(line), + }, process.argv.slice(2)) +);`, + ]; + return parts.join('\n\n') + '\n'; +} diff --git a/packages/client-generator/src/emitters/inline-runtime.ts b/packages/client-generator/src/emitters/inline-runtime.ts index 519c67e56c..e8b4accffc 100644 --- a/packages/client-generator/src/emitters/inline-runtime.ts +++ b/packages/client-generator/src/emitters/inline-runtime.ts @@ -76,6 +76,11 @@ function embedModule(name: RuntimeModuleName): string { return parts.join('').trim(); } +/** The cli engine (`runCli` + types) stripped for embedding into `.cli.ts`. */ +export function embedCliRuntime(): string { + return embedModule('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/emitters/runtime-sources.ts b/packages/client-generator/src/emitters/runtime-sources.ts index b31eaf6eb2..a4ff6d7d3e 100644 --- a/packages/client-generator/src/emitters/runtime-sources.ts +++ b/packages/client-generator/src/emitters/runtime-sources.ts @@ -25,7 +25,7 @@ export const RUNTIME_SOURCES = { 'paginate.ts': "import type { OperationArgs } from './create-client.js';\nimport type { PaginationSpec, QueryValue, RequestOptions } from './types.js';\n\n/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `params` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nexport function resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `params[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nexport async function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.params?.[spec.param];\n while (true) {\n const params = { ...args.params };\n if (cursor !== undefined) params[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, params }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `params[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.params?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call(\n { ...args, params: { ...args.params, [spec.param]: position } },\n init\n );\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nexport async function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\nexport type LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nexport function linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nexport async function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let params = args.params;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, params }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n params = { ...args.params, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nexport async function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n", 'cli.ts': - "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. */\n positionals: Array<{ name: string; description?: string }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n binName: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const groups = new Set(commands.filter((c) => c.group).map((c) => c.group as string));\n let command: CliCommand | undefined;\n let rest: string[];\n if (groups.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n command = commands.find((c) => c.group === undefined && c.name === argv[0]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]}` };\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return { kind: 'usage-error', message: `${command.name} is not paginated; --page-all only applies to paginated operations` };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nfunction envPrefix(binName: string): string {\n return binName.replace(/[^A-Za-z0-9]+/g, '_').replace(/([a-z0-9])([A-Z])/g, '$1_$2').toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = { ...(auth.apiKey as Record | undefined), [scheme.key]: value };\n }\n }\n }\n return auth;\n}\n\nfunction renderHelp(commands: CliCommand[], binName: string, topic?: CliCommand | string): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [command.group] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(` --${flag.name} <${flag.type}>${choices}${required} ${flag.description ?? ''}`.trimEnd());\n }\n }\n return lines;\n }\n const scope = typeof topic === 'string' ? commands.filter((c) => c.group === topic) : commands;\n const lines = typeof topic === 'string' ? [`Usage: ${binName} ${topic} …`, '', 'Commands:'] : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n if (seenGroups.has(command.group)) continue;\n seenGroups.add(command.group);\n lines.push(` ${command.group} `);\n continue;\n }\n lines.push(` ${[command.group, command.name].filter(Boolean).join(' ')} ${command.summary ?? ''}`.trimEnd());\n }\n lines.push('', `Run ${binName} --help for command details; ${binName} schema prints its schemas.`);\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(\n headers: Record,\n secrets: string[]\n): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: CliCommand[],\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(commands, wiring.binName, invocation.topic)) stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(invocation.command.schemas ?? {}, null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n if (command.blob && globals.output === undefined) {\n return fail(4, { message: `${command.name} downloads a file: pass --output `, operationId: command.name });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, { message: `Invalid --json body: ${(error as Error).message}`, operationId: command.name });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (url: string, init: { method?: string; headers?: Record; body?: unknown }) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n try {\n if (globals.pageAll && !globals.dryRun) {\n const pages = (wiring.client[command.name] as { pages: (variables?: unknown) => AsyncIterable }).pages;\n for await (const page of pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = wiring.client[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", + "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. */\n positionals: Array<{ name: string; description?: string }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n binName: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const groups = new Set(commands.filter((c) => c.group).map((c) => c.group as string));\n let command: CliCommand | undefined;\n let rest: string[];\n if (groups.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n command = commands.find((c) => c.group === undefined && c.name === argv[0]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]}` };\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nfunction envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [command.group] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${flag.description ?? ''}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope = typeof topic === 'string' ? commands.filter((c) => c.group === topic) : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n if (seenGroups.has(command.group)) continue;\n seenGroups.add(command.group);\n lines.push(` ${command.group} `);\n continue;\n }\n lines.push(\n ` ${[command.group, command.name].filter(Boolean).join(' ')} ${command.summary ?? ''}`.trimEnd()\n );\n }\n lines.push(\n '',\n `Run ${binName} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: CliCommand[],\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(commands, wiring.binName, invocation.topic)) stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(invocation.command.schemas ?? {}, null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", } as const; export type RuntimeModuleName = keyof typeof RUNTIME_SOURCES; diff --git a/packages/client-generator/src/generators/__tests__/cli.test.ts b/packages/client-generator/src/generators/__tests__/cli.test.ts new file mode 100644 index 0000000000..d90ec63684 --- /dev/null +++ b/packages/client-generator/src/generators/__tests__/cli.test.ts @@ -0,0 +1,85 @@ +import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js'; +import { cliGenerator, cliSample } from '../cli.js'; +import { builtinGenerators, validateGenerators } from '../index.js'; + +const STRING: SchemaModel = { kind: 'scalar', scalar: 'string' }; + +const MODEL: ApiModel = { + title: 'Cafe', + version: '1.0.0', + serverUrl: 'https://api.cafe.example', + services: [ + { + name: 'Orders', + operations: [ + { + name: 'getOrder', + specName: 'getOrder', + method: 'get', + path: '/orders/{orderId}', + tags: ['Orders'], + pathParams: [{ name: 'orderId', in: 'path', required: true, schema: STRING }], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'ref', name: 'Order' }, + }, + ], + errorResponses: [], + }, + ], + }, + ], + schemas: [ + { + name: 'Order', + schema: { kind: 'object', properties: [{ name: 'id', schema: STRING, required: true }] }, + }, + ], + securitySchemes: [], +} as unknown as ApiModel; + +describe('cliGenerator', () => { + it('emits .cli.ts beside the client, wiring zod only when co-selected', () => { + const files = cliGenerator({ + model: MODEL, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: {}, + selected: ['sdk', 'cli'], + }); + expect(files).toHaveLength(1); + expect(files[0].path).toBe('/out/client.cli.ts'); + expect(files[0].content).not.toContain('zodValidation'); + + const withZod = cliGenerator({ + model: MODEL, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: {}, + selected: ['sdk', 'zod', 'cli'], + }); + expect(withZod[0].content).toContain('use(zodValidation());'); + }); + + it('requires sdk and rejects result mode', () => { + expect(() => validateGenerators(['cli'], {})).toThrow(/requires the "sdk" generator/); + expect(() => validateGenerators(['sdk', 'cli'], { errorMode: 'result' })).toThrow( + /does not support --error-mode "result"/ + ); + expect(() => validateGenerators(['sdk', 'cli'], {})).not.toThrow(); + expect(builtinGenerators().has('cli')).toBe(true); + }); + + it('renders a shell x-codeSamples snippet per operation', () => { + const op = MODEL.services[0].operations[0]; + const sample = cliSample(op, { model: MODEL, emit: {} }); + expect(sample).toMatchObject({ lang: 'shell', label: 'CLI' }); + expect(sample?.source).toContain('Orders getOrder '); + }); +}); diff --git a/packages/client-generator/src/generators/cli.ts b/packages/client-generator/src/generators/cli.ts new file mode 100644 index 0000000000..bf460820f7 --- /dev/null +++ b/packages/client-generator/src/generators/cli.ts @@ -0,0 +1,46 @@ +import { join } from 'node:path'; + +import { commandData, renderCliModule } from '../emitters/cli.js'; +import type { OperationModel } from '../intermediate-representation/model.js'; +import { anchor } from './anchor.js'; +import type { CodeSample, Generator, SampleContext } from './types.js'; + +/** + * The cli generator: a bin-ready `.cli.ts` — a zero-dependency, typed + * command-line interface over the sibling sdk client (typed flags, `--json` + * bodies, env auth, `--page-all`, SSE/blob output, a documented exit-code + * contract). Requires `sdk` (throw mode); wires zod validation when co-selected. + */ +export const cliGenerator: Generator = ({ model, outputPath, emit, selected }) => { + const { dir, stem } = anchor(outputPath); + const content = renderCliModule(model, { + stem, + importExt: emit.importExt ?? 'js', + runtime: emit.runtime ?? 'inline', + zodSelected: selected?.includes('zod') ?? false, + binName: stem, + pagination: emit.pagination, + }); + return [{ path: join(dir, `${stem}.cli.ts`), content }]; +}; + +/** One shell invocation per operation — feeds `x-codeSamples` for docs. */ +export function cliSample(op: OperationModel, ctx: SampleContext): CodeSample | undefined { + const command = commandData(ctx.model, { pagination: ctx.emit.pagination }).find( + (candidate) => candidate.name === op.name + ); + if (command === undefined) return undefined; + const words = [ + 'client', + ...(command.group ? [command.group] : []), + command.name, + ...command.positionals.map((positional) => `<${positional.name}>`), + ...command.flags.filter((flag) => flag.required).map((flag) => `--${flag.name} <${flag.type}>`), + ...(command.body ? ["--json ''"] : []), + ]; + return { + lang: 'shell', + label: 'CLI', + source: `npx tsx client.cli.ts ${words.slice(1).join(' ')}\n`, + }; +} diff --git a/packages/client-generator/src/generators/index.ts b/packages/client-generator/src/generators/index.ts index da77edef45..b40be0fa62 100644 --- a/packages/client-generator/src/generators/index.ts +++ b/packages/client-generator/src/generators/index.ts @@ -1,4 +1,5 @@ import type { EmitOptions } from '../emitters/emit-options.js'; +import { cliGenerator, cliSample } from './cli.js'; import { goGenerator, goSample } from './go.js'; import { BUILTIN_META, validateSelection, type BuiltinMeta } from './meta.js'; import { mockGenerator } from './mock.js'; @@ -32,6 +33,7 @@ const RUNS: Record> = 'tanstack-query-solid': { run: tanstackQueryGenerator('solid') }, swr: { run: swrGenerator }, mock: { run: mockGenerator }, + cli: { run: cliGenerator, sample: cliSample }, python: { run: pythonGenerator, sample: pythonSample }, go: { run: goGenerator, sample: goSample }, }; diff --git a/packages/client-generator/src/generators/meta.ts b/packages/client-generator/src/generators/meta.ts index 9149d5448e..5b58b4bff1 100644 --- a/packages/client-generator/src/generators/meta.ts +++ b/packages/client-generator/src/generators/meta.ts @@ -51,6 +51,13 @@ export const BUILTIN_META: Record = { requires: ['sdk'], load: () => import('./mock.js').then((m) => ({ run: m.mockGenerator })), }, + // cli dispatches through the sdk's instance client and relies on thrown ApiError + // for its exit-code mapping, so it is sdk-bound and throw-only. + cli: { + requires: ['sdk'], + errorModes: ['throw'], + load: () => import('./cli.js').then((m) => ({ run: m.cliGenerator, sample: m.cliSample })), + }, // python emits a standalone full Python SDK (httpx) — no TypeScript involved, // so a python-only selection never loads the `typescript` package. python: { diff --git a/packages/client-generator/src/generators/types.ts b/packages/client-generator/src/generators/types.ts index e16fffef9b..b9e1eaabd8 100644 --- a/packages/client-generator/src/generators/types.ts +++ b/packages/client-generator/src/generators/types.ts @@ -27,6 +27,7 @@ export type GeneratorName = | 'swr' | 'transformers' | 'mock' + | 'cli' | 'python' | 'go'; @@ -39,6 +40,8 @@ export type GeneratorInput = { outputMode: OutputMode; /** Emit options — serverUrl, runtime, and the generator knobs (dateType, mockData, …); see `EmitOptions`. */ emit: EmitOptions; + /** Every generator name in the run — lets a generator adapt to co-selection (cli wires zod validation when `zod` is selected). */ + selected?: string[]; }; /** diff --git a/packages/client-generator/src/pipeline.ts b/packages/client-generator/src/pipeline.ts index ee6316eeee..1077c6070f 100644 --- a/packages/client-generator/src/pipeline.ts +++ b/packages/client-generator/src/pipeline.ts @@ -50,6 +50,7 @@ export function runGenerators( outputPath: options.outputPath, outputMode: options.outputMode, emit: options.emit, + selected: options.generators, })) { if (seen.has(file.path)) { throw new Error(`Generator conflict: ${file.path} already emitted by an earlier generator`); diff --git a/packages/client-generator/src/runtime/cli.ts b/packages/client-generator/src/runtime/cli.ts index 1f7073c45d..b8063863f0 100644 --- a/packages/client-generator/src/runtime/cli.ts +++ b/packages/client-generator/src/runtime/cli.ts @@ -386,15 +386,18 @@ export async function runCli( if (body !== undefined) variables.body = body; const argument = Object.keys(variables).length > 0 ? variables : undefined; + // The client's methods are typed per-operation; the dispatcher only needs "callable + // by name", so one localized widening here keeps the emitted wiring cast-free. + const methods = wiring.client as Record; try { if (globals.pageAll && !globals.dryRun) { - const pages = ( - wiring.client[command.name] as { pages: (variables?: unknown) => AsyncIterable } - ).pages; - for await (const page of pages(argument)) stdout(JSON.stringify(page)); + const paginated = methods[command.name] as { + pages: (variables?: unknown) => AsyncIterable; + }; + for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page)); return 0; } - const method = wiring.client[command.name] as (variables?: unknown) => Promise; + const method = methods[command.name] as (variables?: unknown) => Promise; const result = await method(argument); if (globals.dryRun) { stdout(JSON.stringify(captured, null, 2)); From f1b23394d1b9d8f4b874fd23c63d8afd59ee75d9 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 18:00:41 +0300 Subject: [PATCH 030/211] test(client-generator): cli generator e2e against a live server --- packages/client-generator/src/emitters/cli.ts | 2 +- .../generate-client/cli-consumer/.gitignore | 1 + .../generate-client/cli-consumer/server.ts | 68 +++++++ tests/e2e/generate-client/cli.test.ts | 171 ++++++++++++++++++ tests/e2e/generate-client/fixtures/cli.yaml | 93 ++++++++++ 5 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/generate-client/cli-consumer/.gitignore create mode 100644 tests/e2e/generate-client/cli-consumer/server.ts create mode 100644 tests/e2e/generate-client/cli.test.ts create mode 100644 tests/e2e/generate-client/fixtures/cli.yaml diff --git a/packages/client-generator/src/emitters/cli.ts b/packages/client-generator/src/emitters/cli.ts index 49a7f20d62..c0c28e61fb 100644 --- a/packages/client-generator/src/emitters/cli.ts +++ b/packages/client-generator/src/emitters/cli.ts @@ -70,7 +70,7 @@ export function commandData( : undefined; const responseSchema = jsonSuccessSchema(op); commands.push({ - ...(service.name !== 'Default' ? { group: service.name } : {}), + ...(op.tags.length > 0 ? { group: op.tags[0] } : {}), name: op.name, ...(op.summary !== undefined ? { summary: op.summary } : {}), method: op.method.toUpperCase(), diff --git a/tests/e2e/generate-client/cli-consumer/.gitignore b/tests/e2e/generate-client/cli-consumer/.gitignore new file mode 100644 index 0000000000..684bec4c9f --- /dev/null +++ b/tests/e2e/generate-client/cli-consumer/.gitignore @@ -0,0 +1 @@ +client/ diff --git a/tests/e2e/generate-client/cli-consumer/server.ts b/tests/e2e/generate-client/cli-consumer/server.ts new file mode 100644 index 0000000000..f4ae155bec --- /dev/null +++ b/tests/e2e/generate-client/cli-consumer/server.ts @@ -0,0 +1,68 @@ +// Throwaway HTTP server for the cli e2e: canned cursor pages, an echo POST, and a +// request log (`/__test__/log`) so the test can assert query strings, auth headers, +// forwarded bodies, and hit counts. +import * as http from 'node:http'; + +const PORT = Number.parseInt(process.env.CLI_SERVER_PORT ?? '3108', 10); + +type LogEntry = { method: string; url: string; authorization?: string; body?: string }; +const requestLog: LogEntry[] = []; + +const server = http.createServer(async (req, res) => { + const method = req.method ?? 'GET'; + const url = req.url ?? '/'; + const { pathname, searchParams } = new URL(url, 'http://localhost'); + + if (pathname === '/__test__/ready') { + res.writeHead(200).end('ok'); + return; + } + if (pathname === '/__test__/log') { + res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(requestLog)); + return; + } + + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + const body = chunks.length > 0 ? Buffer.concat(chunks).toString('utf-8') : undefined; + requestLog.push({ + method, + url, + ...(typeof req.headers.authorization === 'string' + ? { authorization: req.headers.authorization } + : {}), + ...(body !== undefined ? { body } : {}), + }); + + const json = (status: number, payload: unknown) => + res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify(payload)); + + if (method === 'GET' && pathname === '/orders') { + if (searchParams.get('cursor') === 'page-2') { + json(200, { orders: [{ id: 'ord_2', item: 'tea', quantity: 1 }] }); + } else { + json(200, { + orders: [{ id: 'ord_1', item: 'espresso', quantity: 2 }], + nextCursor: 'page-2', + }); + } + return; + } + if (method === 'POST' && pathname === '/orders') { + json(201, { id: 'ord_new', ...JSON.parse(body ?? '{}') }); + return; + } + if (method === 'GET' && pathname.startsWith('/orders/')) { + json(200, { id: pathname.split('/').pop(), item: 'espresso', quantity: 2 }); + return; + } + if (method === 'GET' && pathname === '/ping') { + res.writeHead(204).end(); + return; + } + json(404, { message: 'not found' }); +}); + +server.listen(PORT, () => { + process.stdout.write(`cli e2e server on :${PORT}\n`); +}); diff --git a/tests/e2e/generate-client/cli.test.ts b/tests/e2e/generate-client/cli.test.ts new file mode 100644 index 0000000000..a798ba47cd --- /dev/null +++ b/tests/e2e/generate-client/cli.test.ts @@ -0,0 +1,171 @@ +import { spawnSync, type ChildProcess } from 'node:child_process'; +import { existsSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { generate, killServer, startServer, tsxBin } from './helpers.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixture = join(__dirname, 'fixtures/cli.yaml'); +const consumerDir = join(__dirname, 'cli-consumer'); +const clientDir = join(consumerDir, 'client'); + +const SERVER_PORT = 3108; +const SERVER_BASE = `http://127.0.0.1:${SERVER_PORT}`; + +/** Run the generated CLI with tsx; returns exit code + parsed streams. */ +function runCliBin(args: string[], env: Record = {}) { + const result = spawnSync(tsxBin, [join(clientDir, 'client.cli.ts'), ...args], { + cwd: clientDir, + encoding: 'utf-8', + env: { ...process.env, ...env }, + }); + return { code: result.status, stdout: result.stdout, stderr: result.stderr }; +} + +async function serverLog(): Promise< + Array<{ method: string; url: string; authorization?: string; body?: string }> +> { + const response = await fetch(`${SERVER_BASE}/__test__/log`); + return response.json(); +} + +describe('generate-client cli generator (end-to-end)', () => { + let serverProcess: ChildProcess | undefined; + + beforeAll(async () => { + generate(fixture, join(clientDir, 'client.ts'), [ + '--generator', + 'sdk', + '--generator', + 'zod', + '--generator', + 'cli', + ]); + writeFileSync(join(clientDir, 'package.json'), JSON.stringify({ type: 'module' }), 'utf-8'); + serverProcess = await startServer( + join(consumerDir, 'server.ts'), + consumerDir, + { CLI_SERVER_PORT: String(SERVER_PORT) }, + SERVER_BASE, + 'cli-e2e-server' + ); + }, 60_000); + + afterAll(async () => { + if (serverProcess) await killServer(serverProcess); + rmSync(clientDir, { recursive: true, force: true }); + }); + + it('generates client.cli.ts and strict tsc (types: node) accepts it', () => { + expect(existsSync(join(clientDir, 'client.cli.ts'))).toBe(true); + writeFileSync( + join(clientDir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + module: 'nodenext', + moduleResolution: 'nodenext', + target: 'es2022', + lib: ['ES2022', 'DOM'], + strict: true, + noEmit: true, + skipLibCheck: true, + types: ['node'], + }, + include: ['**/*.ts'], + }), + 'utf-8' + ); + const tsc = spawnSync(join(__dirname, '../../../node_modules/.bin/tsc'), ['-p', clientDir], { + encoding: 'utf-8', + }); + expect(tsc.status, `${tsc.stdout}\n${tsc.stderr}`).toBe(0); + }, 120_000); + + it('typed flags reach the query string; bearer auth comes from the env prefix', async () => { + const before = (await serverLog()).length; + const { code, stdout } = runCliBin( + ['orders', 'listOrders', '--status', 'open', '--limit', '2'], + { CLIENT_TOKEN: 'e2e-token' } + ); + expect(code).toBe(0); + expect(JSON.parse(stdout).orders).toHaveLength(1); + const entries = await serverLog(); + const hit = entries[entries.length - 1]; + expect(entries.length).toBe(before + 1); + expect(hit.url).toContain('status=open'); + expect(hit.url).toContain('limit=2'); + expect(hit.authorization).toBe('Bearer e2e-token'); + }); + + it('positional path params and --json bodies dispatch correctly', async () => { + const get = runCliBin(['orders', 'getOrder', 'ord_42']); + expect(get.code).toBe(0); + expect(JSON.parse(get.stdout).id).toBe('ord_42'); + + writeFileSync(join(clientDir, 'order.json'), '{"item":"latte","quantity":1}', 'utf-8'); + const create = runCliBin(['orders', 'createOrder', '--json', '@order.json']); + expect(create.code).toBe(0); + expect(JSON.parse(create.stdout)).toMatchObject({ id: 'ord_new', item: 'latte' }); + const entries = await serverLog(); + expect(entries[entries.length - 1].body).toBe('{"item":"latte","quantity":1}'); + }); + + it('zod validation failures exit 3 without hitting the server', async () => { + const before = (await serverLog()).length; + const { code, stderr } = runCliBin([ + 'orders', + 'createOrder', + '--json', + '{"item":"latte","quantity":0}', + ]); + expect(code).toBe(3); + expect(JSON.parse(stderr).error.code).toBe(3); + expect((await serverLog()).length).toBe(before); + }); + + it('--dry-run prints the prepared request and sends nothing; the token is redacted', async () => { + const before = (await serverLog()).length; + const { code, stdout } = runCliBin(['orders', 'getOrder', 'ord_1', '--dry-run'], { + CLIENT_TOKEN: 'secret-token', + }); + expect(code).toBe(0); + const captured = JSON.parse(stdout); + expect(captured.url).toContain('/orders/ord_1'); + expect(captured.method).toBe('GET'); + expect(JSON.stringify(captured)).not.toContain('secret-token'); + expect((await serverLog()).length).toBe(before); + }); + + it('--page-all follows the cursor and prints one JSON page per line', () => { + const { code, stdout } = runCliBin(['orders', 'listOrders', '--page-all']); + expect(code).toBe(0); + const pages = stdout + .trim() + .split('\n') + .map((line) => JSON.parse(line)); + expect(pages).toHaveLength(2); + expect(pages[0].orders[0].id).toBe('ord_1'); + expect(pages[1].orders[0].id).toBe('ord_2'); + }); + + it('schema prints the request schema; usage errors exit 4; --help exits 0', () => { + const schema = runCliBin(['schema', 'createOrder']); + expect(schema.code).toBe(0); + expect(JSON.parse(schema.stdout).request).toBeDefined(); + + const usage = runCliBin(['orders', 'listOrders', '--bogus', 'x']); + expect(usage.code).toBe(4); + expect(JSON.parse(usage.stderr).error.code).toBe(4); + + const help = runCliBin(['--help']); + expect(help.code).toBe(0); + expect(help.stdout).toContain('orders'); + }); + + it('void results print nothing and exit 0', () => { + const { code, stdout } = runCliBin(['ping']); + expect(code).toBe(0); + expect(stdout.trim()).toBe(''); + }); +}); diff --git a/tests/e2e/generate-client/fixtures/cli.yaml b/tests/e2e/generate-client/fixtures/cli.yaml new file mode 100644 index 0000000000..987a1190a8 --- /dev/null +++ b/tests/e2e/generate-client/fixtures/cli.yaml @@ -0,0 +1,93 @@ +openapi: 3.1.0 +info: + title: Cafe CLI API + version: 1.0.0 +servers: + - url: http://localhost:3108 +security: + - BearerAuth: [] +paths: + /orders: + get: + operationId: listOrders + summary: List orders, one cursor page at a time. + tags: [orders] + x-redocly-pagination: + style: cursor + cursorParam: cursor + nextCursor: /nextCursor + limitParam: limit + items: /orders + parameters: + - name: cursor + in: query + schema: { type: string } + - name: limit + in: query + schema: { type: integer } + - name: status + in: query + schema: { type: string, enum: [open, closed] } + responses: + '200': + description: One page of orders. + content: + application/json: + schema: + type: object + required: [orders] + properties: + orders: + type: array + items: { $ref: '#/components/schemas/Order' } + nextCursor: { type: string } + post: + operationId: createOrder + summary: Place an order. + tags: [orders] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/Order' } + responses: + '201': + description: The created order. + content: + application/json: + schema: { $ref: '#/components/schemas/Order' } + /orders/{orderId}: + get: + operationId: getOrder + summary: One order by id. + tags: [orders] + parameters: + - name: orderId + in: path + required: true + schema: { type: string } + responses: + '200': + description: The order. + content: + application/json: + schema: { $ref: '#/components/schemas/Order' } + /ping: + get: + operationId: ping + responses: + '204': + description: Alive. +components: + securitySchemes: + BearerAuth: + type: http + scheme: bearer + schemas: + Order: + type: object + required: [item, quantity] + properties: + id: { type: string } + item: { type: string } + quantity: { type: integer, minimum: 1 } From a4b840a188bd532d38c7f6921a1c793b9eb15a61 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 18:10:38 +0300 Subject: [PATCH 031/211] docs(client-generator): cli generator docs, changeset, and harness bar --- .changeset/cli-generator.md | 6 +++ docs/@v2/commands/generate-client.md | 2 +- docs/@v2/configuration/reference/client.md | 2 +- docs/@v2/guides/use-generated-client.md | 32 +++++++++++++- .../harness/generate-client/github.harness.ts | 5 +++ tests/harness/generate-client/helpers.ts | 44 ++++++++++++++++--- .../generate-client/rebilly.harness.ts | 6 ++- 7 files changed, 88 insertions(+), 9 deletions(-) create mode 100644 .changeset/cli-generator.md diff --git a/.changeset/cli-generator.md b/.changeset/cli-generator.md new file mode 100644 index 0000000000..be6d837b32 --- /dev/null +++ b/.changeset/cli-generator.md @@ -0,0 +1,6 @@ +--- +'@redocly/client-generator': minor +'@redocly/cli': minor +--- + +Added a built-in `cli` generator — `.cli.ts`, a bin-ready, zero-dependency command-line interface over the generated client: typed flags from query parameters, positional path parameters, `--json` bodies (inline, `@file`, or stdin), credentials from prefixed environment variables, `--dry-run`, `--page-all` pagination streaming, SSE and blob output, a documented exit-code contract, and zod request validation when the `zod` generator is co-selected. diff --git a/docs/@v2/commands/generate-client.md b/docs/@v2/commands/generate-client.md index d62da582c4..c498872bb3 100644 --- a/docs/@v2/commands/generate-client.md +++ b/docs/@v2/commands/generate-client.md @@ -40,7 +40,7 @@ redocly generate-client [--help] [--version] | `--output-mode` | string | File layout. See [Choose an output mode](#choose-an-output-mode).
**Possible values:** `single`, `split`. Default value is `single`. | | `--runtime` | string | Where the client's engine lives. See [Choose a runtime](#choose-a-runtime).
**Possible values:** `inline`, `package`. Default value is `inline`. | | `--import-ext` | string | Extension in generated relative imports. See [Run with Node directly](../guides/use-generated-client.md#run-with-node-directly).
**Possible values:** `js` (the tsc/bundler convention), `ts` (for Node's built-in type stripping). Default value is `js`. | -| `--generator` | [string] | Generator to run — a built-in name (`tanstack-query` also has `-vue`/`-svelte`/`-solid` variants; `python`/`go` emit full Python and Go SDKs) or a custom generator's path or package; repeat the flag to run several. Default value is `sdk`. See [Generators](../guides/use-generated-client.md#generators). | +| `--generator` | [string] | Generator to run — a built-in name (`tanstack-query` also has `-vue`/`-svelte`/`-solid` variants; `python`/`go` emit full Python and Go SDKs; `cli` emits a command-line interface) or a custom generator's path or package; repeat the flag to run several. Default value is `sdk`. See [Generators](../guides/use-generated-client.md#generators). | | `--args-style` | string | How operation inputs are passed. See [Argument style](../guides/use-generated-client.md#argument-style).
**Possible values:** `flat`, `grouped`. Default value is `flat`. | | `--error-mode` | string | How operations report HTTP errors. See [Error handling](../guides/use-generated-client.md#error-handling).
**Possible values:** `throw`, `result`. Default value is `throw`. | | `--date-type` | string | Type of `date`/`date-time` fields; pair `Date` with the `transformers` generator.
**Possible values:** `string`, `Date`. Default value is `string`. | diff --git a/docs/@v2/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md index b149572ba5..d351536735 100644 --- a/docs/@v2/configuration/reference/client.md +++ b/docs/@v2/configuration/reference/client.md @@ -19,7 +19,7 @@ For runs without a configuration file, declare pagination per operation with the | Option | Type | Description | | ---------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `generators` | [string] | Generators to run, in order. Each entry is a built-in name (`sdk`, `zod`, `tanstack-query` — or its `-vue`/`-svelte`/`-solid` variants — `swr`, `mock`, `transformers`, `python`, `go`) or a custom generator's path or package name. | +| `generators` | [string] | Generators to run, in order. Each entry is a built-in name (`sdk`, `zod`, `tanstack-query` — or its `-vue`/`-svelte`/`-solid` variants — `swr`, `mock`, `transformers`, `cli`, `python`, `go`) or a custom generator's path or package name. | | `outputMode` | string | File layout: `single` or `split`. | | `runtime` | string | Runtime distribution: `inline` or `package`. | | `importExt` | string | Extension in generated relative imports: `js` (default, for tsc and bundlers) or `ts` (for Node's built-in type stripping). | diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index c70b652d93..cfabaccf13 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -18,14 +18,44 @@ Incompatible selections fail fast with an explanation. | `swr` | `.swr.ts` — [SWR](https://swr.vercel.app) hooks. | `swr` `^2` | | `mock` | `.mocks.ts` — [MSW](https://mswjs.io) v2 handlers + `create` factories. | `msw` `^2` (+ `@faker-js/faker` for `--mock-data faker`) | | `transformers` | `.transformers.ts` — `transform` functions that parse wire dates to `Date`. | none | +| `cli` | `.cli.ts` — a bin-ready [command-line interface](#generated-cli) over the client: typed flags, `--json` bodies, env auth, `--page-all`. | none | ```sh redocly generate-client openapi.yaml --output src/client.ts --generator sdk --generator zod --generator mock ``` -`tanstack-query` and `swr` wrap the throw-mode `sdk` functions, so they require `--error-mode throw`; `transformers` requires `--date-type Date`. +`tanstack-query`, `swr`, and `cli` wrap the throw-mode `sdk` client, so they require `--error-mode throw`; `transformers` requires `--date-type Date`. See the [`zod`](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/zod), [`tanstack-query`](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/tanstack-query), and [`mock`](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/mock) examples. +### Generated CLI + +The `cli` generator emits `.cli.ts` — a zero-dependency, bin-ready command-line interface over the generated client. +Path params are positional, query params become typed `--kebab-name` flags (enums list their choices in `--help`, array params repeat the flag), and JSON request bodies arrive via `--json ''`, `--json @file.json`, or `--json @-` (stdin). +When `zod` is co-selected, requests are validated before they are sent. + +```sh +redocly generate-client openapi.yaml --output src/client.ts --generator sdk --generator cli +npx tsx src/client.cli.ts orders listOrders --status open --limit 10 +npx tsx src/client.cli.ts orders createOrder --json @order.json +npx tsx src/client.cli.ts orders listOrders --page-all # one JSON page per line +npx tsx src/client.cli.ts schema createOrder # request/response schemas +``` + +Credentials come from environment variables derived from the file stem (constant-cased): bearer → `_TOKEN` (or `--token`), basic → `_USERNAME`/`_PASSWORD`, apiKey → `_API_KEY_`. +`--server-url` overrides the baked server; `--dry-run` prints the prepared request (credentials redacted) without sending it; blob responses require `--output `; SSE operations stream events as one JSON object per line. + +Exit codes are a documented contract, and errors print one JSON object to stderr so stdout stays clean for piping: + +| Code | Meaning | +| ---- | --------------------------------------------------- | +| 0 | success | +| 1 | API error (status other than 401/403) | +| 2 | auth error (401/403) | +| 3 | validation error (zod co-selected) | +| 4 | usage error (unknown command or flag, bad `--json`) | + +To ship it as a real bin, compile with `tsc` and point `package.json`'s `bin` at the compiled file. + ### Python SDK The `python` generator emits a self-contained `.py` next to the configured output — a full Python SDK over [httpx](https://www.python-httpx.org/) (`pip install httpx`, Python ≥ 3.9): diff --git a/tests/harness/generate-client/github.harness.ts b/tests/harness/generate-client/github.harness.ts index 71153816d1..5f76dfd701 100644 --- a/tests/harness/generate-client/github.harness.ts +++ b/tests/harness/generate-client/github.harness.ts @@ -2,6 +2,7 @@ // scale case that shook out the strict-mode reserved-word and +1/-1 naming bugs. import { + cliBar, fetchGithubDescription, goBar, hasGo, @@ -21,6 +22,10 @@ describe('github REST description', () => { typescriptBar(github); }); + it('cli passes strict Node-typed tsc', () => { + cliBar(github); + }); + it.skipIf(!hasPython)('python imports cleanly', () => { pythonBar(github); }); diff --git a/tests/harness/generate-client/helpers.ts b/tests/harness/generate-client/helpers.ts index 0624b20809..000fd93ce6 100644 --- a/tests/harness/generate-client/helpers.ts +++ b/tests/harness/generate-client/helpers.ts @@ -8,7 +8,7 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { generate, strictTypecheck } from '../../e2e/generate-client/helpers.js'; +import { generate, repoRoot, strictTypecheck } from '../../e2e/generate-client/helpers.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -33,10 +33,15 @@ export const hasPython = spawnSync('python3', ['--version']).status === 0; export const hasHttpx = hasPython && spawnSync('python3', ['-c', 'import httpx']).status === 0; export const hasGo = spawnSync('go', ['version']).status === 0; -/** Generate with `--generator ` into a fresh temp dir; returns the dir. */ -export function generateWith(generator: string, description: string): string { - const dir = mkdtempSync(join(tmpdir(), `harness-${generator}-`)); - generate(description, join(dir, 'client.ts'), ['--generator', generator]); +/** Generate with `--generator ` (repeatable) into a fresh temp dir; returns the dir. */ +export function generateWith(generator: string | string[], description: string): string { + const generators = Array.isArray(generator) ? generator : [generator]; + const dir = mkdtempSync(join(tmpdir(), `harness-${generators.join('-')}-`)); + generate( + description, + join(dir, 'client.ts'), + generators.flatMap((name) => ['--generator', name]) + ); return dir; } @@ -47,6 +52,35 @@ export function typescriptBar(description: string): void { strictTypecheck(dir); } +/** CLI bar: the generated `.cli.ts` passes a strict, Node-typed `tsc --noEmit`. */ +export function cliBar(description: string): void { + const dir = generateWith(['sdk', 'cli'], description); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ type: 'module' }), 'utf-8'); + writeFileSync( + join(dir, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + module: 'nodenext', + moduleResolution: 'nodenext', + target: 'es2022', + lib: ['ES2022', 'DOM'], + strict: true, + noEmit: true, + skipLibCheck: true, + types: ['node'], + // The temp dir has no node_modules; resolve @types/node from the repo. + typeRoots: [join(repoRoot, 'node_modules/@types')], + }, + include: ['**/*.ts'], + }), + 'utf-8' + ); + const tsc = spawnSync(join(repoRoot, 'node_modules/.bin/tsc'), ['-p', dir], { + encoding: 'utf-8', + }); + expect(tsc.status, `${tsc.stdout}\n${tsc.stderr}`).toBe(0); +} + /** * Python bar: `import client` (executes every dataclass declaration — catches * duplicate fields and bad defaults); syntax-only `py_compile` when httpx is absent. diff --git a/tests/harness/generate-client/rebilly.harness.ts b/tests/harness/generate-client/rebilly.harness.ts index e6134eff8c..f6cd2eb5a6 100644 --- a/tests/harness/generate-client/rebilly.harness.ts +++ b/tests/harness/generate-client/rebilly.harness.ts @@ -4,7 +4,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { goBar, hasGo, hasPython, pythonBar, typescriptBar } from './helpers.js'; +import { cliBar, goBar, hasGo, hasPython, pythonBar, typescriptBar } from './helpers.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const rebilly = join(__dirname, '../../smoke/rebilly/rebilly-description.yaml'); @@ -14,6 +14,10 @@ describe('rebilly description', () => { typescriptBar(rebilly); }); + it('cli passes strict Node-typed tsc', () => { + cliBar(rebilly); + }); + it.skipIf(!hasPython)('python imports cleanly', () => { pythonBar(rebilly); }); From 8b8b5fc2b50c71e85750ebacf401ee611bf0d1e8 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 18:23:11 +0300 Subject: [PATCH 032/211] feat(client-generator): PHP reserved words in the neutral naming toolkit --- .../src/authoring/__tests__/naming.test.ts | 3 +++ .../client-generator/src/authoring/naming.ts | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/client-generator/src/authoring/__tests__/naming.test.ts b/packages/client-generator/src/authoring/__tests__/naming.test.ts index fc7bc4d723..046800cda2 100644 --- a/packages/client-generator/src/authoring/__tests__/naming.test.ts +++ b/packages/client-generator/src/authoring/__tests__/naming.test.ts @@ -36,5 +36,8 @@ describe('identifierFor', () => { expect(identifierFor('order', { style: 'camel', reserved: RESERVED_WORDS.python })).toBe( 'order' ); + expect(identifierFor('class', { style: 'camel', reserved: RESERVED_WORDS.php })).toBe('class_'); + expect(identifierFor('list', { style: 'camel', reserved: RESERVED_WORDS.php })).toBe('list_'); + expect(identifierFor('echo', { style: 'camel', reserved: RESERVED_WORDS.php })).toBe('echo_'); }); }); diff --git a/packages/client-generator/src/authoring/naming.ts b/packages/client-generator/src/authoring/naming.ts index 2cfa919486..110e5ddb6e 100644 --- a/packages/client-generator/src/authoring/naming.ts +++ b/packages/client-generator/src/authoring/naming.ts @@ -32,7 +32,7 @@ export const casing = { }; /** Keyword sets for the first-party target languages; authors pass their own set for others. */ -export const RESERVED_WORDS: Record<'typescript' | 'python' | 'go', ReadonlySet> = { +export const RESERVED_WORDS: Record<'typescript' | 'python' | 'go' | 'php', ReadonlySet> = { // prettier-ignore typescript: new Set([ 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', @@ -54,6 +54,20 @@ export const RESERVED_WORDS: Record<'typescript' | 'python' | 'go', ReadonlySet< 'for', 'func', 'go', 'goto', 'if', 'import', 'interface', 'map', 'package', 'range', 'return', 'select', 'struct', 'switch', 'type', 'var', ]), + // PHP keywords + compile-time constants are case-insensitive; the set stays lowercase + // because `identifierFor` matches on the lowercased candidate. + // prettier-ignore + php: new Set([ + 'abstract', 'and', 'array', 'as', 'break', 'callable', 'case', 'catch', 'class', 'clone', + 'const', 'continue', 'declare', 'default', 'die', 'do', 'echo', 'else', 'elseif', 'empty', + 'enddeclare', 'endfor', 'endforeach', 'endif', 'endswitch', 'endwhile', 'enum', 'eval', + 'exit', 'extends', 'final', 'finally', 'fn', 'for', 'foreach', 'function', 'global', 'goto', + 'if', 'implements', 'include', 'instanceof', 'insteadof', 'interface', 'isset', 'list', + 'match', 'namespace', 'new', 'or', 'print', 'private', 'protected', 'public', 'readonly', + 'require', 'return', 'static', 'switch', 'throw', 'trait', 'try', 'unset', 'use', 'var', + 'while', 'xor', 'yield', 'true', 'false', 'null', 'int', 'float', 'bool', 'string', 'void', + 'iterable', 'object', 'mixed', 'never', 'self', 'parent', + ]), }; /** From a9336cd11e60aaf600328d5a707d7e8b6ef81f47 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 2 Aug 2026 18:28:24 +0300 Subject: [PATCH 033/211] feat(client-generator): embedded PHP runtime (curl, retries, pagination, SSE, multipart) --- .../client-generator/php-runtime/runtime.php | 457 ++++++++++++++++++ .../scripts/generate-runtime-sources.mjs | 14 + .../src/emitters/php-runtime-sources.ts | 3 + .../__tests__/php-runtime-embed.test.ts | 35 ++ 4 files changed, 509 insertions(+) create mode 100644 packages/client-generator/php-runtime/runtime.php create mode 100644 packages/client-generator/src/emitters/php-runtime-sources.ts create mode 100644 packages/client-generator/src/generators/__tests__/php-runtime-embed.test.ts diff --git a/packages/client-generator/php-runtime/runtime.php b/packages/client-generator/php-runtime/runtime.php new file mode 100644 index 0000000000..ea2389b6ce --- /dev/null +++ b/packages/client-generator/php-runtime/runtime.php @@ -0,0 +1,457 @@ += 8.1, zero Composer dependencies; HTTP over the curl extension. +// The generated file re-declares the namespace; the embed strips this header. + +declare(strict_types=1); + +namespace RedoclyClientRuntime; + +/** A response with status >= 400, decoded body attached. */ +final class ApiError extends \RuntimeException +{ + public function __construct( + public readonly string $url, + public readonly int $status, + public readonly string $reason, + public readonly mixed $body, + ) { + parent::__construct("HTTP {$status} {$reason} for {$url}"); + } +} + +/** Every attempt timed out or failed to connect. */ +final class TimeoutError extends \RuntimeException +{ + public function __construct( + public readonly string $url, + public readonly ?float $timeout, + public readonly int $attempts, + ) { + $seconds = $timeout === null ? 'the configured timeout' : "{$timeout}s"; + parent::__construct("Request to {$url} timed out after {$seconds} ({$attempts} attempt(s))"); + } +} + +/** One parsed `text/event-stream` frame. */ +final class ServerSentEvent +{ + public function __construct( + public readonly string $event, + public readonly mixed $data, + public readonly ?string $id = null, + public readonly ?int $retry = null, + ) { + } +} + +/** + * Per-instance configuration. + * `auth`: `['bearer' => string|callable, 'basic' => ['username' => ..., 'password' => ...], 'apiKey' => [scheme => string|callable]]`. + * `retry`: `['attempts' => int, 'delay' => float, 'strategy' => 'exponential'|'fixed', 'retryOn' => callable]`. + * `middleware`: callables `fn(array $request, callable $next): array` around each attempt. + */ +final class Config +{ + public function __construct( + public string $serverUrl = '', + public array $auth = [], + public ?float $timeout = null, + public array $retry = [], + public array $middleware = [], + public string $clientHeader = 'redocly-client-generator', + ) { + } +} + +/** Resolve a literal-or-callable credential to its string value. */ +function resolveToken(mixed $provider): string +{ + return is_callable($provider) ? (string) $provider() : (string) $provider; +} + +/** + * Apply the first fully-configured security alternative. `$security` is an OR-list + * of AND-sets of specs: `['kind' => 'bearer'|'basic'|'apiKey', 'scheme' => ..., 'name' => ?, 'in' => ?]`. + * Returns `[headers, query, cookies]`. + */ +function resolveAuth(array $security, array $auth): array +{ + foreach ($security as $andSet) { + $headers = []; + $query = []; + $cookies = []; + $satisfied = true; + foreach ($andSet as $spec) { + if ($spec['kind'] === 'bearer' && isset($auth['bearer'])) { + $headers['Authorization'] = 'Bearer ' . resolveToken($auth['bearer']); + } elseif ($spec['kind'] === 'basic' && isset($auth['basic'])) { + $headers['Authorization'] = + 'Basic ' . base64_encode($auth['basic']['username'] . ':' . $auth['basic']['password']); + } elseif ($spec['kind'] === 'apiKey' && isset($auth['apiKey'][$spec['scheme']])) { + $value = resolveToken($auth['apiKey'][$spec['scheme']]); + if ($spec['in'] === 'query') { + $query[$spec['name']] = $value; + } elseif ($spec['in'] === 'cookie') { + $cookies[] = $spec['name'] . '=' . rawurlencode($value); + } else { + $headers[$spec['name']] = $value; + } + } else { + $satisfied = false; + break; + } + } + if ($satisfied) { + return [$headers, $query, $cookies]; + } + } + return [[], [], []]; +} + +/** Substitute `{param}` templates with encoded values and prefix the server URL. */ +function buildUrl(string $serverUrl, string $path, array $pathParams): string +{ + foreach ($pathParams as $name => $value) { + $path = str_replace('{' . $name . '}', rawurlencode((string) $value), $path); + } + return rtrim($serverUrl, '/') . $path; +} + +/** The default retry predicate: 5xx, 429, and transport timeouts/connect failures. */ +function defaultRetryOn(array $context): bool +{ + if (($context['timedOut'] ?? false) === true) { + return true; + } + $status = $context['status'] ?? 0; + return $status >= 500 || $status === 429; +} + +/** Delay before the next attempt: `Retry-After` wins; otherwise jittered (fixed|exponential) backoff. */ +function retryDelay(int $attempt, array $retry, ?string $retryAfter): float +{ + if ($retryAfter !== null && ctype_digit($retryAfter)) { + return (float) $retryAfter; + } + $base = (float) ($retry['delay'] ?? 1.0); + $strategy = $retry['strategy'] ?? 'exponential'; + $delay = $strategy === 'fixed' ? $base : $base * (2 ** ($attempt - 1)); + return $delay * (0.5 + mt_rand() / mt_getrandmax() / 2); +} + +/** One raw curl exchange. Returns `['status', 'reason', 'headers', 'body', 'url', 'timedOut']`. */ +function rawSend(Config $config, array $request): array +{ + $url = $request['url']; + $query = $request['query'] ?? []; + if ($query !== []) { + $url .= (str_contains($url, '?') ? '&' : '?') . http_build_query($query); + } + $handle = curl_init($url); + $headerLines = []; + foreach ($request['headers'] ?? [] as $name => $value) { + $headerLines[] = $name . ': ' . $value; + } + $responseHeaders = []; + curl_setopt_array($handle, [ + CURLOPT_CUSTOMREQUEST => $request['method'], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headerLines, + CURLOPT_HEADERFUNCTION => function ($ch, string $line) use (&$responseHeaders): int { + $parts = explode(':', $line, 2); + if (count($parts) === 2) { + $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]); + } + return strlen($line); + }, + ]); + if (($request['body'] ?? null) !== null) { + curl_setopt($handle, CURLOPT_POSTFIELDS, $request['body']); + } + if ($config->timeout !== null) { + curl_setopt($handle, CURLOPT_TIMEOUT_MS, (int) round($config->timeout * 1000)); + } + $body = curl_exec($handle); + $errno = curl_errno($handle); + $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE); + $effectiveUrl = (string) curl_getinfo($handle, CURLINFO_EFFECTIVE_URL); + curl_close($handle); + if ($errno !== 0) { + $timedOut = $errno === CURLE_OPERATION_TIMEDOUT || $errno === CURLE_COULDNT_CONNECT; + return [ + 'status' => 0, + 'reason' => curl_strerror($errno) ?? 'transport error', + 'headers' => [], + 'body' => '', + 'url' => $effectiveUrl, + 'timedOut' => $timedOut, + ]; + } + return [ + 'status' => $status, + 'reason' => '', + 'headers' => $responseHeaders, + 'body' => is_string($body) ? $body : '', + 'url' => $effectiveUrl, + 'timedOut' => false, + ]; +} + +/** + * Send with retries and middleware. `$request` carries `operationId`, `method`, `url`, + * `headers`, `query`, and optional `body`/`contentType`/`idempotencyKey`. + * Returns the raw response array; callers map status >= 400 to `ApiError`. + */ +function send(Config $config, array $request): array +{ + $headers = $request['headers'] ?? []; + $headers['X-Redocly-Client'] = $config->clientHeader; + if (($request['contentType'] ?? null) !== null) { + $headers['Content-Type'] = $request['contentType']; + } + if (($request['idempotencyKey'] ?? null) !== null) { + $headers['Idempotency-Key'] = $request['idempotencyKey']; + } + $request['headers'] = $headers; + + $handler = fn (array $req): array => rawSend($config, $req); + foreach (array_reverse($config->middleware) as $middleware) { + $next = $handler; + $handler = fn (array $req): array => $middleware($req, $next); + } + + $attempts = max(1, (int) ($config->retry['attempts'] ?? 3)); + $retryOn = $config->retry['retryOn'] ?? __NAMESPACE__ . '\\defaultRetryOn'; + $response = null; + for ($attempt = 1; $attempt <= $attempts; $attempt++) { + $response = $handler($request); + $context = [ + 'status' => $response['status'], + 'timedOut' => $response['timedOut'], + 'attempt' => $attempt, + 'operationId' => $request['operationId'] ?? '', + ]; + if ($attempt === $attempts || !$retryOn($context)) { + break; + } + $seconds = retryDelay($attempt, $config->retry, $response['headers']['retry-after'] ?? null); + usleep((int) round($seconds * 1_000_000)); + } + if ($response['timedOut']) { + throw new TimeoutError($response['url'], $config->timeout, $attempts); + } + if ($response['status'] === 0) { + throw new \RuntimeException("Request to {$response['url']} failed: {$response['reason']}"); + } + return $response; +} + +/** Decoded JSON body (assoc arrays), or null for empty bodies. */ +function decodeJson(array $response): mixed +{ + if ($response['body'] === '') { + return null; + } + return json_decode($response['body'], true); +} + +/** `ApiError` from a non-2xx response. */ +function apiErrorFrom(array $response): ApiError +{ + return new ApiError($response['url'], $response['status'], $response['reason'], decodeJson($response)); +} + +/** Walk an RFC 6901 JSON pointer over decoded JSON; null on any miss. */ +function resolvePointer(mixed $data, string $pointer): mixed +{ + if ($pointer === '') { + return $data; + } + foreach (explode('/', substr($pointer, 1)) as $token) { + $key = str_replace(['~1', '~0'], ['/', '~'], $token); + if (!is_array($data) || !array_key_exists($key, $data)) { + return null; + } + $data = $data[$key]; + } + return $data; +} + +/** The `rel="next"` target of a `Link` header, or null. */ +function linkNext(?string $header): ?string +{ + if ($header === null) { + return null; + } + foreach (explode(',', $header) as $part) { + if (preg_match('/<([^>]+)>\s*;[^,]*rel="?next"?/', trim($part), $match) === 1) { + return $match[1]; + } + } + return null; +} + +/** + * Auto-pagination: `$call(array $params): [mixed rawPage, array $response]`, `$spec` is the + * normalized rule (`style`, `param`, `nextCursor`, `hasMore`, `items`), `$base` the caller's + * query params. Yields raw decoded pages; generated wrappers hydrate them into models. + */ +function iterPages(callable $call, array $spec, array $base): \Generator +{ + $params = $base; + $style = $spec['style']; + $seenCursors = []; + $seenLinks = []; + $offset = null; + $page = null; + while (true) { + [$raw, $response] = $call($params); + yield $raw; + if ($style === 'cursor') { + $next = resolvePointer($raw, $spec['nextCursor'] ?? ''); + if (isset($spec['hasMore']) && resolvePointer($raw, $spec['hasMore']) !== true) { + return; + } + if (!is_string($next) || $next === '' || isset($seenCursors[$next])) { + return; + } + $seenCursors[$next] = true; + $params[$spec['param']] = $next; + } elseif ($style === 'link') { + $target = linkNext($response['headers']['link'] ?? null); + if ($target === null || isset($seenLinks[$target])) { + return; + } + $seenLinks[$target] = true; + $parsed = parse_url($target); + $linkParams = []; + parse_str($parsed['query'] ?? '', $linkParams); + $params = array_merge($params, $linkParams); + } else { + $items = resolvePointer($raw, $spec['items'] ?? ''); + $count = is_array($items) ? count($items) : 0; + if ($count === 0) { + return; + } + if ($style === 'offset') { + $offset = ($offset ?? (int) ($base[$spec['param']] ?? 0)) + $count; + $params[$spec['param']] = $offset; + } else { + $page = ($page ?? (int) ($base[$spec['param']] ?? 1)) + 1; + $params[$spec['param']] = $page; + } + } + } +} + +/** Parse one SSE frame; returns `[?ServerSentEvent, ?string lastEventId, ?int retryMs]`. */ +function parseSseFrame(string $frame, bool $jsonData): array +{ + $event = 'message'; + $dataLines = []; + $id = null; + $retry = null; + foreach (explode("\n", str_replace("\r\n", "\n", $frame)) as $line) { + if ($line === '' || str_starts_with($line, ':')) { + continue; + } + $colon = strpos($line, ':'); + $field = $colon === false ? $line : substr($line, 0, $colon); + $value = $colon === false ? '' : ltrim(substr($line, $colon + 1), ' '); + if ($field === 'event') { + $event = $value; + } elseif ($field === 'data') { + $dataLines[] = $value; + } elseif ($field === 'id') { + $id = $value; + } elseif ($field === 'retry' && ctype_digit($value)) { + $retry = (int) $value; + } + } + if ($dataLines === [] && $id === null && $retry === null) { + return [null, null, $retry]; + } + $data = implode("\n", $dataLines); + $decoded = $jsonData && $data !== '' ? json_decode($data, true) : $data; + return [new ServerSentEvent($event, $decoded, $id, $retry), $id, $retry]; +} + +/** + * Stream server-sent events. `$open(array $extraHeaders): \CurlHandle` returns a configured + * (not yet executed) handle; this pump drives it with curl_multi, yields parsed frames, and + * reconnects with `Last-Event-ID` on transient failures (4xx is definitive; backoff <= 30s). + */ +function iterSse(callable $open, bool $jsonData): \Generator +{ + $lastEventId = null; + $retryMs = 3000; + while (true) { + $extra = ['Accept' => 'text/event-stream']; + if ($lastEventId !== null) { + $extra['Last-Event-ID'] = $lastEventId; + } + $handle = $open($extra); + $buffer = ''; + curl_setopt($handle, CURLOPT_WRITEFUNCTION, function ($ch, string $chunk) use (&$buffer): int { + $buffer .= $chunk; + return strlen($chunk); + }); + $multi = curl_multi_init(); + curl_multi_add_handle($multi, $handle); + do { + curl_multi_exec($multi, $running); + if ($running > 0) { + curl_multi_select($multi, 0.1); + } + while (($split = strpos($buffer, "\n\n")) !== false || ($split = strpos($buffer, "\r\n\r\n")) !== false) { + $frameLength = $buffer[$split] === "\r" ? 4 : 2; + $frame = substr($buffer, 0, $split); + $buffer = substr($buffer, $split + $frameLength); + [$event, $id, $retry] = parseSseFrame($frame, $jsonData); + if ($id !== null) { + $lastEventId = $id; + } + if ($retry !== null) { + $retryMs = min($retry, 30000); + } + if ($event !== null) { + yield $event; + } + } + } while ($running > 0); + $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE); + $url = (string) curl_getinfo($handle, CURLINFO_EFFECTIVE_URL); + curl_multi_remove_handle($multi, $handle); + curl_multi_close($multi); + if ($status >= 400 && $status < 500) { + throw new ApiError($url, $status, '', $buffer); + } + // A clean 200 end-of-stream is done; anything else reconnects with Last-Event-ID. + if ($status === 200) { + return; + } + usleep($retryMs * 1000); + } +} + +/** Encode an assoc body as `multipart/form-data`; nested values are JSON parts. Returns `[contentType, body]`. */ +function toMultipart(array $body): array +{ + $boundary = 'redocly-' . bin2hex(random_bytes(12)); + $parts = ''; + foreach ($body as $name => $value) { + $parts .= "--{$boundary}\r\n"; + if (is_array($value)) { + $parts .= "Content-Disposition: form-data; name=\"{$name}\"\r\n"; + $parts .= "Content-Type: application/json\r\n\r\n"; + $parts .= json_encode($value) . "\r\n"; + } else { + $parts .= "Content-Disposition: form-data; name=\"{$name}\"\r\n\r\n"; + $parts .= (is_bool($value) ? ($value ? 'true' : 'false') : (string) $value) . "\r\n"; + } + } + $parts .= "--{$boundary}--\r\n"; + return ['multipart/form-data; boundary=' . $boundary, $parts]; +} diff --git a/packages/client-generator/scripts/generate-runtime-sources.mjs b/packages/client-generator/scripts/generate-runtime-sources.mjs index 45ffd72764..ad463ec410 100644 --- a/packages/client-generator/scripts/generate-runtime-sources.mjs +++ b/packages/client-generator/scripts/generate-runtime-sources.mjs @@ -112,6 +112,20 @@ writeFileSync( ].join('\n') ); +// The PHP runtime embeds the same way (a single curl-only module). +const phpDir = join(pkgRoot, 'php-runtime'); +const phpOut = join(pkgRoot, 'src', 'emitters', 'php-runtime-sources.ts'); +const phpSource = readFileSync(join(phpDir, 'runtime.php'), 'utf-8'); +writeFileSync( + phpOut, + [ + '// GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`.', + // oxfmt (printWidth 100) wraps the over-width const onto a continuation line. + `export const PHP_RUNTIME_SOURCE =\n ${toStringLiteral(phpSource)};`, + '', + ].join('\n') +); + const content = [ '// GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`.', 'export const RUNTIME_SOURCES = {', diff --git a/packages/client-generator/src/emitters/php-runtime-sources.ts b/packages/client-generator/src/emitters/php-runtime-sources.ts new file mode 100644 index 0000000000..02a7664d29 --- /dev/null +++ b/packages/client-generator/src/emitters/php-runtime-sources.ts @@ -0,0 +1,3 @@ +// GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`. +export const PHP_RUNTIME_SOURCE = + "= 8.1, zero Composer dependencies; HTTP over the curl extension.\n// The generated file re-declares the namespace; the embed strips this header.\n\ndeclare(strict_types=1);\n\nnamespace RedoclyClientRuntime;\n\n/** A response with status >= 400, decoded body attached. */\nfinal class ApiError extends \\RuntimeException\n{\n public function __construct(\n public readonly string $url,\n public readonly int $status,\n public readonly string $reason,\n public readonly mixed $body,\n ) {\n parent::__construct(\"HTTP {$status} {$reason} for {$url}\");\n }\n}\n\n/** Every attempt timed out or failed to connect. */\nfinal class TimeoutError extends \\RuntimeException\n{\n public function __construct(\n public readonly string $url,\n public readonly ?float $timeout,\n public readonly int $attempts,\n ) {\n $seconds = $timeout === null ? 'the configured timeout' : \"{$timeout}s\";\n parent::__construct(\"Request to {$url} timed out after {$seconds} ({$attempts} attempt(s))\");\n }\n}\n\n/** One parsed `text/event-stream` frame. */\nfinal class ServerSentEvent\n{\n public function __construct(\n public readonly string $event,\n public readonly mixed $data,\n public readonly ?string $id = null,\n public readonly ?int $retry = null,\n ) {\n }\n}\n\n/**\n * Per-instance configuration.\n * `auth`: `['bearer' => string|callable, 'basic' => ['username' => ..., 'password' => ...], 'apiKey' => [scheme => string|callable]]`.\n * `retry`: `['attempts' => int, 'delay' => float, 'strategy' => 'exponential'|'fixed', 'retryOn' => callable]`.\n * `middleware`: callables `fn(array $request, callable $next): array` around each attempt.\n */\nfinal class Config\n{\n public function __construct(\n public string $serverUrl = '',\n public array $auth = [],\n public ?float $timeout = null,\n public array $retry = [],\n public array $middleware = [],\n public string $clientHeader = 'redocly-client-generator',\n ) {\n }\n}\n\n/** Resolve a literal-or-callable credential to its string value. */\nfunction resolveToken(mixed $provider): string\n{\n return is_callable($provider) ? (string) $provider() : (string) $provider;\n}\n\n/**\n * Apply the first fully-configured security alternative. `$security` is an OR-list\n * of AND-sets of specs: `['kind' => 'bearer'|'basic'|'apiKey', 'scheme' => ..., 'name' => ?, 'in' => ?]`.\n * Returns `[headers, query, cookies]`.\n */\nfunction resolveAuth(array $security, array $auth): array\n{\n foreach ($security as $andSet) {\n $headers = [];\n $query = [];\n $cookies = [];\n $satisfied = true;\n foreach ($andSet as $spec) {\n if ($spec['kind'] === 'bearer' && isset($auth['bearer'])) {\n $headers['Authorization'] = 'Bearer ' . resolveToken($auth['bearer']);\n } elseif ($spec['kind'] === 'basic' && isset($auth['basic'])) {\n $headers['Authorization'] =\n 'Basic ' . base64_encode($auth['basic']['username'] . ':' . $auth['basic']['password']);\n } elseif ($spec['kind'] === 'apiKey' && isset($auth['apiKey'][$spec['scheme']])) {\n $value = resolveToken($auth['apiKey'][$spec['scheme']]);\n if ($spec['in'] === 'query') {\n $query[$spec['name']] = $value;\n } elseif ($spec['in'] === 'cookie') {\n $cookies[] = $spec['name'] . '=' . rawurlencode($value);\n } else {\n $headers[$spec['name']] = $value;\n }\n } else {\n $satisfied = false;\n break;\n }\n }\n if ($satisfied) {\n return [$headers, $query, $cookies];\n }\n }\n return [[], [], []];\n}\n\n/** Substitute `{param}` templates with encoded values and prefix the server URL. */\nfunction buildUrl(string $serverUrl, string $path, array $pathParams): string\n{\n foreach ($pathParams as $name => $value) {\n $path = str_replace('{' . $name . '}', rawurlencode((string) $value), $path);\n }\n return rtrim($serverUrl, '/') . $path;\n}\n\n/** The default retry predicate: 5xx, 429, and transport timeouts/connect failures. */\nfunction defaultRetryOn(array $context): bool\n{\n if (($context['timedOut'] ?? false) === true) {\n return true;\n }\n $status = $context['status'] ?? 0;\n return $status >= 500 || $status === 429;\n}\n\n/** Delay before the next attempt: `Retry-After` wins; otherwise jittered (fixed|exponential) backoff. */\nfunction retryDelay(int $attempt, array $retry, ?string $retryAfter): float\n{\n if ($retryAfter !== null && ctype_digit($retryAfter)) {\n return (float) $retryAfter;\n }\n $base = (float) ($retry['delay'] ?? 1.0);\n $strategy = $retry['strategy'] ?? 'exponential';\n $delay = $strategy === 'fixed' ? $base : $base * (2 ** ($attempt - 1));\n return $delay * (0.5 + mt_rand() / mt_getrandmax() / 2);\n}\n\n/** One raw curl exchange. Returns `['status', 'reason', 'headers', 'body', 'url', 'timedOut']`. */\nfunction rawSend(Config $config, array $request): array\n{\n $url = $request['url'];\n $query = $request['query'] ?? [];\n if ($query !== []) {\n $url .= (str_contains($url, '?') ? '&' : '?') . http_build_query($query);\n }\n $handle = curl_init($url);\n $headerLines = [];\n foreach ($request['headers'] ?? [] as $name => $value) {\n $headerLines[] = $name . ': ' . $value;\n }\n $responseHeaders = [];\n curl_setopt_array($handle, [\n CURLOPT_CUSTOMREQUEST => $request['method'],\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_HTTPHEADER => $headerLines,\n CURLOPT_HEADERFUNCTION => function ($ch, string $line) use (&$responseHeaders): int {\n $parts = explode(':', $line, 2);\n if (count($parts) === 2) {\n $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);\n }\n return strlen($line);\n },\n ]);\n if (($request['body'] ?? null) !== null) {\n curl_setopt($handle, CURLOPT_POSTFIELDS, $request['body']);\n }\n if ($config->timeout !== null) {\n curl_setopt($handle, CURLOPT_TIMEOUT_MS, (int) round($config->timeout * 1000));\n }\n $body = curl_exec($handle);\n $errno = curl_errno($handle);\n $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);\n $effectiveUrl = (string) curl_getinfo($handle, CURLINFO_EFFECTIVE_URL);\n curl_close($handle);\n if ($errno !== 0) {\n $timedOut = $errno === CURLE_OPERATION_TIMEDOUT || $errno === CURLE_COULDNT_CONNECT;\n return [\n 'status' => 0,\n 'reason' => curl_strerror($errno) ?? 'transport error',\n 'headers' => [],\n 'body' => '',\n 'url' => $effectiveUrl,\n 'timedOut' => $timedOut,\n ];\n }\n return [\n 'status' => $status,\n 'reason' => '',\n 'headers' => $responseHeaders,\n 'body' => is_string($body) ? $body : '',\n 'url' => $effectiveUrl,\n 'timedOut' => false,\n ];\n}\n\n/**\n * Send with retries and middleware. `$request` carries `operationId`, `method`, `url`,\n * `headers`, `query`, and optional `body`/`contentType`/`idempotencyKey`.\n * Returns the raw response array; callers map status >= 400 to `ApiError`.\n */\nfunction send(Config $config, array $request): array\n{\n $headers = $request['headers'] ?? [];\n $headers['X-Redocly-Client'] = $config->clientHeader;\n if (($request['contentType'] ?? null) !== null) {\n $headers['Content-Type'] = $request['contentType'];\n }\n if (($request['idempotencyKey'] ?? null) !== null) {\n $headers['Idempotency-Key'] = $request['idempotencyKey'];\n }\n $request['headers'] = $headers;\n\n $handler = fn (array $req): array => rawSend($config, $req);\n foreach (array_reverse($config->middleware) as $middleware) {\n $next = $handler;\n $handler = fn (array $req): array => $middleware($req, $next);\n }\n\n $attempts = max(1, (int) ($config->retry['attempts'] ?? 3));\n $retryOn = $config->retry['retryOn'] ?? __NAMESPACE__ . '\\\\defaultRetryOn';\n $response = null;\n for ($attempt = 1; $attempt <= $attempts; $attempt++) {\n $response = $handler($request);\n $context = [\n 'status' => $response['status'],\n 'timedOut' => $response['timedOut'],\n 'attempt' => $attempt,\n 'operationId' => $request['operationId'] ?? '',\n ];\n if ($attempt === $attempts || !$retryOn($context)) {\n break;\n }\n $seconds = retryDelay($attempt, $config->retry, $response['headers']['retry-after'] ?? null);\n usleep((int) round($seconds * 1_000_000));\n }\n if ($response['timedOut']) {\n throw new TimeoutError($response['url'], $config->timeout, $attempts);\n }\n if ($response['status'] === 0) {\n throw new \\RuntimeException(\"Request to {$response['url']} failed: {$response['reason']}\");\n }\n return $response;\n}\n\n/** Decoded JSON body (assoc arrays), or null for empty bodies. */\nfunction decodeJson(array $response): mixed\n{\n if ($response['body'] === '') {\n return null;\n }\n return json_decode($response['body'], true);\n}\n\n/** `ApiError` from a non-2xx response. */\nfunction apiErrorFrom(array $response): ApiError\n{\n return new ApiError($response['url'], $response['status'], $response['reason'], decodeJson($response));\n}\n\n/** Walk an RFC 6901 JSON pointer over decoded JSON; null on any miss. */\nfunction resolvePointer(mixed $data, string $pointer): mixed\n{\n if ($pointer === '') {\n return $data;\n }\n foreach (explode('/', substr($pointer, 1)) as $token) {\n $key = str_replace(['~1', '~0'], ['/', '~'], $token);\n if (!is_array($data) || !array_key_exists($key, $data)) {\n return null;\n }\n $data = $data[$key];\n }\n return $data;\n}\n\n/** The `rel=\"next\"` target of a `Link` header, or null. */\nfunction linkNext(?string $header): ?string\n{\n if ($header === null) {\n return null;\n }\n foreach (explode(',', $header) as $part) {\n if (preg_match('/<([^>]+)>\\s*;[^,]*rel=\"?next\"?/', trim($part), $match) === 1) {\n return $match[1];\n }\n }\n return null;\n}\n\n/**\n * Auto-pagination: `$call(array $params): [mixed rawPage, array $response]`, `$spec` is the\n * normalized rule (`style`, `param`, `nextCursor`, `hasMore`, `items`), `$base` the caller's\n * query params. Yields raw decoded pages; generated wrappers hydrate them into models.\n */\nfunction iterPages(callable $call, array $spec, array $base): \\Generator\n{\n $params = $base;\n $style = $spec['style'];\n $seenCursors = [];\n $seenLinks = [];\n $offset = null;\n $page = null;\n while (true) {\n [$raw, $response] = $call($params);\n yield $raw;\n if ($style === 'cursor') {\n $next = resolvePointer($raw, $spec['nextCursor'] ?? '');\n if (isset($spec['hasMore']) && resolvePointer($raw, $spec['hasMore']) !== true) {\n return;\n }\n if (!is_string($next) || $next === '' || isset($seenCursors[$next])) {\n return;\n }\n $seenCursors[$next] = true;\n $params[$spec['param']] = $next;\n } elseif ($style === 'link') {\n $target = linkNext($response['headers']['link'] ?? null);\n if ($target === null || isset($seenLinks[$target])) {\n return;\n }\n $seenLinks[$target] = true;\n $parsed = parse_url($target);\n $linkParams = [];\n parse_str($parsed['query'] ?? '', $linkParams);\n $params = array_merge($params, $linkParams);\n } else {\n $items = resolvePointer($raw, $spec['items'] ?? '');\n $count = is_array($items) ? count($items) : 0;\n if ($count === 0) {\n return;\n }\n if ($style === 'offset') {\n $offset = ($offset ?? (int) ($base[$spec['param']] ?? 0)) + $count;\n $params[$spec['param']] = $offset;\n } else {\n $page = ($page ?? (int) ($base[$spec['param']] ?? 1)) + 1;\n $params[$spec['param']] = $page;\n }\n }\n }\n}\n\n/** Parse one SSE frame; returns `[?ServerSentEvent, ?string lastEventId, ?int retryMs]`. */\nfunction parseSseFrame(string $frame, bool $jsonData): array\n{\n $event = 'message';\n $dataLines = [];\n $id = null;\n $retry = null;\n foreach (explode(\"\\n\", str_replace(\"\\r\\n\", \"\\n\", $frame)) as $line) {\n if ($line === '' || str_starts_with($line, ':')) {\n continue;\n }\n $colon = strpos($line, ':');\n $field = $colon === false ? $line : substr($line, 0, $colon);\n $value = $colon === false ? '' : ltrim(substr($line, $colon + 1), ' ');\n if ($field === 'event') {\n $event = $value;\n } elseif ($field === 'data') {\n $dataLines[] = $value;\n } elseif ($field === 'id') {\n $id = $value;\n } elseif ($field === 'retry' && ctype_digit($value)) {\n $retry = (int) $value;\n }\n }\n if ($dataLines === [] && $id === null && $retry === null) {\n return [null, null, $retry];\n }\n $data = implode(\"\\n\", $dataLines);\n $decoded = $jsonData && $data !== '' ? json_decode($data, true) : $data;\n return [new ServerSentEvent($event, $decoded, $id, $retry), $id, $retry];\n}\n\n/**\n * Stream server-sent events. `$open(array $extraHeaders): \\CurlHandle` returns a configured\n * (not yet executed) handle; this pump drives it with curl_multi, yields parsed frames, and\n * reconnects with `Last-Event-ID` on transient failures (4xx is definitive; backoff <= 30s).\n */\nfunction iterSse(callable $open, bool $jsonData): \\Generator\n{\n $lastEventId = null;\n $retryMs = 3000;\n while (true) {\n $extra = ['Accept' => 'text/event-stream'];\n if ($lastEventId !== null) {\n $extra['Last-Event-ID'] = $lastEventId;\n }\n $handle = $open($extra);\n $buffer = '';\n curl_setopt($handle, CURLOPT_WRITEFUNCTION, function ($ch, string $chunk) use (&$buffer): int {\n $buffer .= $chunk;\n return strlen($chunk);\n });\n $multi = curl_multi_init();\n curl_multi_add_handle($multi, $handle);\n do {\n curl_multi_exec($multi, $running);\n if ($running > 0) {\n curl_multi_select($multi, 0.1);\n }\n while (($split = strpos($buffer, \"\\n\\n\")) !== false || ($split = strpos($buffer, \"\\r\\n\\r\\n\")) !== false) {\n $frameLength = $buffer[$split] === \"\\r\" ? 4 : 2;\n $frame = substr($buffer, 0, $split);\n $buffer = substr($buffer, $split + $frameLength);\n [$event, $id, $retry] = parseSseFrame($frame, $jsonData);\n if ($id !== null) {\n $lastEventId = $id;\n }\n if ($retry !== null) {\n $retryMs = min($retry, 30000);\n }\n if ($event !== null) {\n yield $event;\n }\n }\n } while ($running > 0);\n $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);\n $url = (string) curl_getinfo($handle, CURLINFO_EFFECTIVE_URL);\n curl_multi_remove_handle($multi, $handle);\n curl_multi_close($multi);\n if ($status >= 400 && $status < 500) {\n throw new ApiError($url, $status, '', $buffer);\n }\n // A clean 200 end-of-stream is done; anything else reconnects with Last-Event-ID.\n if ($status === 200) {\n return;\n }\n usleep($retryMs * 1000);\n }\n}\n\n/** Encode an assoc body as `multipart/form-data`; nested values are JSON parts. Returns `[contentType, body]`. */\nfunction toMultipart(array $body): array\n{\n $boundary = 'redocly-' . bin2hex(random_bytes(12));\n $parts = '';\n foreach ($body as $name => $value) {\n $parts .= \"--{$boundary}\\r\\n\";\n if (is_array($value)) {\n $parts .= \"Content-Disposition: form-data; name=\\\"{$name}\\\"\\r\\n\";\n $parts .= \"Content-Type: application/json\\r\\n\\r\\n\";\n $parts .= json_encode($value) . \"\\r\\n\";\n } else {\n $parts .= \"Content-Disposition: form-data; name=\\\"{$name}\\\"\\r\\n\\r\\n\";\n $parts .= (is_bool($value) ? ($value ? 'true' : 'false') : (string) $value) . \"\\r\\n\";\n }\n }\n $parts .= \"--{$boundary}--\\r\\n\";\n return ['multipart/form-data; boundary=' . $boundary, $parts];\n}\n"; diff --git a/packages/client-generator/src/generators/__tests__/php-runtime-embed.test.ts b/packages/client-generator/src/generators/__tests__/php-runtime-embed.test.ts new file mode 100644 index 0000000000..c1abf9f776 --- /dev/null +++ b/packages/client-generator/src/generators/__tests__/php-runtime-embed.test.ts @@ -0,0 +1,35 @@ +import { spawnSync } from 'node:child_process'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { PHP_RUNTIME_SOURCE } from '../../emitters/php-runtime-sources.js'; + +const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const hasPhp = spawnSync('php', ['--version']).status === 0; + +describe('PHP_RUNTIME_SOURCE (the embedded PHP runtime)', () => { + it('embeds the load-bearing declarations', () => { + for (const declaration of [ + 'final class ApiError extends \\RuntimeException', + 'final class TimeoutError extends \\RuntimeException', + 'function resolveAuth(', + 'function buildUrl(', + 'function send(Config $config', + 'function iterPages(', + 'function iterSse(', + 'function toMultipart(', + 'Idempotency-Key', + 'retry-after', + ]) { + expect(PHP_RUNTIME_SOURCE).toContain(declaration); + } + }); + + it.skipIf(!hasPhp)('the runtime module passes php -l', () => { + const result = spawnSync('php', ['-l', 'runtime.php'], { + cwd: join(pkgRoot, 'php-runtime'), + encoding: 'utf-8', + }); + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + }); +}); From 9685e235e94d3cda54af3ea3ada25f5fb5929890 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 09:08:57 +0300 Subject: [PATCH 034/211] =?UTF-8?q?feat(client-generator):=20PHP=20SDK=20g?= =?UTF-8?q?enerator=20=E2=80=94=20models,=20client,=20pagination,=20SSE,?= =?UTF-8?q?=20multipart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client-generator/php-runtime/runtime.php | 22 +- .../src/emitters/php-runtime-sources.ts | 2 +- .../__tests__/language-dogfooding.test.ts | 3 +- .../src/generators/__tests__/php.test.ts | 355 ++++++++ .../client-generator/src/generators/index.ts | 2 + .../client-generator/src/generators/meta.ts | 4 + .../client-generator/src/generators/php.ts | 769 ++++++++++++++++++ .../client-generator/src/generators/types.ts | 3 +- 8 files changed, 1152 insertions(+), 8 deletions(-) create mode 100644 packages/client-generator/src/generators/__tests__/php.test.ts create mode 100644 packages/client-generator/src/generators/php.ts diff --git a/packages/client-generator/php-runtime/runtime.php b/packages/client-generator/php-runtime/runtime.php index ea2389b6ce..713af714a9 100644 --- a/packages/client-generator/php-runtime/runtime.php +++ b/packages/client-generator/php-runtime/runtime.php @@ -141,14 +141,26 @@ function retryDelay(int $attempt, array $retry, ?string $retryAfter): float return $delay * (0.5 + mt_rand() / mt_getrandmax() / 2); } +/** Append query params in form style: list values repeat the key (`tag=a&tag=b`). */ +function appendQuery(string $url, array $query): string +{ + $pairs = []; + foreach ($query as $name => $value) { + foreach (is_array($value) ? $value : [$value] as $single) { + $encoded = is_bool($single) ? ($single ? 'true' : 'false') : (string) $single; + $pairs[] = rawurlencode($name) . '=' . rawurlencode($encoded); + } + } + if ($pairs === []) { + return $url; + } + return $url . (str_contains($url, '?') ? '&' : '?') . implode('&', $pairs); +} + /** One raw curl exchange. Returns `['status', 'reason', 'headers', 'body', 'url', 'timedOut']`. */ function rawSend(Config $config, array $request): array { - $url = $request['url']; - $query = $request['query'] ?? []; - if ($query !== []) { - $url .= (str_contains($url, '?') ? '&' : '?') . http_build_query($query); - } + $url = appendQuery($request['url'], $request['query'] ?? []); $handle = curl_init($url); $headerLines = []; foreach ($request['headers'] ?? [] as $name => $value) { diff --git a/packages/client-generator/src/emitters/php-runtime-sources.ts b/packages/client-generator/src/emitters/php-runtime-sources.ts index 02a7664d29..02597c46c1 100644 --- a/packages/client-generator/src/emitters/php-runtime-sources.ts +++ b/packages/client-generator/src/emitters/php-runtime-sources.ts @@ -1,3 +1,3 @@ // GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`. export const PHP_RUNTIME_SOURCE = - "= 8.1, zero Composer dependencies; HTTP over the curl extension.\n// The generated file re-declares the namespace; the embed strips this header.\n\ndeclare(strict_types=1);\n\nnamespace RedoclyClientRuntime;\n\n/** A response with status >= 400, decoded body attached. */\nfinal class ApiError extends \\RuntimeException\n{\n public function __construct(\n public readonly string $url,\n public readonly int $status,\n public readonly string $reason,\n public readonly mixed $body,\n ) {\n parent::__construct(\"HTTP {$status} {$reason} for {$url}\");\n }\n}\n\n/** Every attempt timed out or failed to connect. */\nfinal class TimeoutError extends \\RuntimeException\n{\n public function __construct(\n public readonly string $url,\n public readonly ?float $timeout,\n public readonly int $attempts,\n ) {\n $seconds = $timeout === null ? 'the configured timeout' : \"{$timeout}s\";\n parent::__construct(\"Request to {$url} timed out after {$seconds} ({$attempts} attempt(s))\");\n }\n}\n\n/** One parsed `text/event-stream` frame. */\nfinal class ServerSentEvent\n{\n public function __construct(\n public readonly string $event,\n public readonly mixed $data,\n public readonly ?string $id = null,\n public readonly ?int $retry = null,\n ) {\n }\n}\n\n/**\n * Per-instance configuration.\n * `auth`: `['bearer' => string|callable, 'basic' => ['username' => ..., 'password' => ...], 'apiKey' => [scheme => string|callable]]`.\n * `retry`: `['attempts' => int, 'delay' => float, 'strategy' => 'exponential'|'fixed', 'retryOn' => callable]`.\n * `middleware`: callables `fn(array $request, callable $next): array` around each attempt.\n */\nfinal class Config\n{\n public function __construct(\n public string $serverUrl = '',\n public array $auth = [],\n public ?float $timeout = null,\n public array $retry = [],\n public array $middleware = [],\n public string $clientHeader = 'redocly-client-generator',\n ) {\n }\n}\n\n/** Resolve a literal-or-callable credential to its string value. */\nfunction resolveToken(mixed $provider): string\n{\n return is_callable($provider) ? (string) $provider() : (string) $provider;\n}\n\n/**\n * Apply the first fully-configured security alternative. `$security` is an OR-list\n * of AND-sets of specs: `['kind' => 'bearer'|'basic'|'apiKey', 'scheme' => ..., 'name' => ?, 'in' => ?]`.\n * Returns `[headers, query, cookies]`.\n */\nfunction resolveAuth(array $security, array $auth): array\n{\n foreach ($security as $andSet) {\n $headers = [];\n $query = [];\n $cookies = [];\n $satisfied = true;\n foreach ($andSet as $spec) {\n if ($spec['kind'] === 'bearer' && isset($auth['bearer'])) {\n $headers['Authorization'] = 'Bearer ' . resolveToken($auth['bearer']);\n } elseif ($spec['kind'] === 'basic' && isset($auth['basic'])) {\n $headers['Authorization'] =\n 'Basic ' . base64_encode($auth['basic']['username'] . ':' . $auth['basic']['password']);\n } elseif ($spec['kind'] === 'apiKey' && isset($auth['apiKey'][$spec['scheme']])) {\n $value = resolveToken($auth['apiKey'][$spec['scheme']]);\n if ($spec['in'] === 'query') {\n $query[$spec['name']] = $value;\n } elseif ($spec['in'] === 'cookie') {\n $cookies[] = $spec['name'] . '=' . rawurlencode($value);\n } else {\n $headers[$spec['name']] = $value;\n }\n } else {\n $satisfied = false;\n break;\n }\n }\n if ($satisfied) {\n return [$headers, $query, $cookies];\n }\n }\n return [[], [], []];\n}\n\n/** Substitute `{param}` templates with encoded values and prefix the server URL. */\nfunction buildUrl(string $serverUrl, string $path, array $pathParams): string\n{\n foreach ($pathParams as $name => $value) {\n $path = str_replace('{' . $name . '}', rawurlencode((string) $value), $path);\n }\n return rtrim($serverUrl, '/') . $path;\n}\n\n/** The default retry predicate: 5xx, 429, and transport timeouts/connect failures. */\nfunction defaultRetryOn(array $context): bool\n{\n if (($context['timedOut'] ?? false) === true) {\n return true;\n }\n $status = $context['status'] ?? 0;\n return $status >= 500 || $status === 429;\n}\n\n/** Delay before the next attempt: `Retry-After` wins; otherwise jittered (fixed|exponential) backoff. */\nfunction retryDelay(int $attempt, array $retry, ?string $retryAfter): float\n{\n if ($retryAfter !== null && ctype_digit($retryAfter)) {\n return (float) $retryAfter;\n }\n $base = (float) ($retry['delay'] ?? 1.0);\n $strategy = $retry['strategy'] ?? 'exponential';\n $delay = $strategy === 'fixed' ? $base : $base * (2 ** ($attempt - 1));\n return $delay * (0.5 + mt_rand() / mt_getrandmax() / 2);\n}\n\n/** One raw curl exchange. Returns `['status', 'reason', 'headers', 'body', 'url', 'timedOut']`. */\nfunction rawSend(Config $config, array $request): array\n{\n $url = $request['url'];\n $query = $request['query'] ?? [];\n if ($query !== []) {\n $url .= (str_contains($url, '?') ? '&' : '?') . http_build_query($query);\n }\n $handle = curl_init($url);\n $headerLines = [];\n foreach ($request['headers'] ?? [] as $name => $value) {\n $headerLines[] = $name . ': ' . $value;\n }\n $responseHeaders = [];\n curl_setopt_array($handle, [\n CURLOPT_CUSTOMREQUEST => $request['method'],\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_HTTPHEADER => $headerLines,\n CURLOPT_HEADERFUNCTION => function ($ch, string $line) use (&$responseHeaders): int {\n $parts = explode(':', $line, 2);\n if (count($parts) === 2) {\n $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);\n }\n return strlen($line);\n },\n ]);\n if (($request['body'] ?? null) !== null) {\n curl_setopt($handle, CURLOPT_POSTFIELDS, $request['body']);\n }\n if ($config->timeout !== null) {\n curl_setopt($handle, CURLOPT_TIMEOUT_MS, (int) round($config->timeout * 1000));\n }\n $body = curl_exec($handle);\n $errno = curl_errno($handle);\n $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);\n $effectiveUrl = (string) curl_getinfo($handle, CURLINFO_EFFECTIVE_URL);\n curl_close($handle);\n if ($errno !== 0) {\n $timedOut = $errno === CURLE_OPERATION_TIMEDOUT || $errno === CURLE_COULDNT_CONNECT;\n return [\n 'status' => 0,\n 'reason' => curl_strerror($errno) ?? 'transport error',\n 'headers' => [],\n 'body' => '',\n 'url' => $effectiveUrl,\n 'timedOut' => $timedOut,\n ];\n }\n return [\n 'status' => $status,\n 'reason' => '',\n 'headers' => $responseHeaders,\n 'body' => is_string($body) ? $body : '',\n 'url' => $effectiveUrl,\n 'timedOut' => false,\n ];\n}\n\n/**\n * Send with retries and middleware. `$request` carries `operationId`, `method`, `url`,\n * `headers`, `query`, and optional `body`/`contentType`/`idempotencyKey`.\n * Returns the raw response array; callers map status >= 400 to `ApiError`.\n */\nfunction send(Config $config, array $request): array\n{\n $headers = $request['headers'] ?? [];\n $headers['X-Redocly-Client'] = $config->clientHeader;\n if (($request['contentType'] ?? null) !== null) {\n $headers['Content-Type'] = $request['contentType'];\n }\n if (($request['idempotencyKey'] ?? null) !== null) {\n $headers['Idempotency-Key'] = $request['idempotencyKey'];\n }\n $request['headers'] = $headers;\n\n $handler = fn (array $req): array => rawSend($config, $req);\n foreach (array_reverse($config->middleware) as $middleware) {\n $next = $handler;\n $handler = fn (array $req): array => $middleware($req, $next);\n }\n\n $attempts = max(1, (int) ($config->retry['attempts'] ?? 3));\n $retryOn = $config->retry['retryOn'] ?? __NAMESPACE__ . '\\\\defaultRetryOn';\n $response = null;\n for ($attempt = 1; $attempt <= $attempts; $attempt++) {\n $response = $handler($request);\n $context = [\n 'status' => $response['status'],\n 'timedOut' => $response['timedOut'],\n 'attempt' => $attempt,\n 'operationId' => $request['operationId'] ?? '',\n ];\n if ($attempt === $attempts || !$retryOn($context)) {\n break;\n }\n $seconds = retryDelay($attempt, $config->retry, $response['headers']['retry-after'] ?? null);\n usleep((int) round($seconds * 1_000_000));\n }\n if ($response['timedOut']) {\n throw new TimeoutError($response['url'], $config->timeout, $attempts);\n }\n if ($response['status'] === 0) {\n throw new \\RuntimeException(\"Request to {$response['url']} failed: {$response['reason']}\");\n }\n return $response;\n}\n\n/** Decoded JSON body (assoc arrays), or null for empty bodies. */\nfunction decodeJson(array $response): mixed\n{\n if ($response['body'] === '') {\n return null;\n }\n return json_decode($response['body'], true);\n}\n\n/** `ApiError` from a non-2xx response. */\nfunction apiErrorFrom(array $response): ApiError\n{\n return new ApiError($response['url'], $response['status'], $response['reason'], decodeJson($response));\n}\n\n/** Walk an RFC 6901 JSON pointer over decoded JSON; null on any miss. */\nfunction resolvePointer(mixed $data, string $pointer): mixed\n{\n if ($pointer === '') {\n return $data;\n }\n foreach (explode('/', substr($pointer, 1)) as $token) {\n $key = str_replace(['~1', '~0'], ['/', '~'], $token);\n if (!is_array($data) || !array_key_exists($key, $data)) {\n return null;\n }\n $data = $data[$key];\n }\n return $data;\n}\n\n/** The `rel=\"next\"` target of a `Link` header, or null. */\nfunction linkNext(?string $header): ?string\n{\n if ($header === null) {\n return null;\n }\n foreach (explode(',', $header) as $part) {\n if (preg_match('/<([^>]+)>\\s*;[^,]*rel=\"?next\"?/', trim($part), $match) === 1) {\n return $match[1];\n }\n }\n return null;\n}\n\n/**\n * Auto-pagination: `$call(array $params): [mixed rawPage, array $response]`, `$spec` is the\n * normalized rule (`style`, `param`, `nextCursor`, `hasMore`, `items`), `$base` the caller's\n * query params. Yields raw decoded pages; generated wrappers hydrate them into models.\n */\nfunction iterPages(callable $call, array $spec, array $base): \\Generator\n{\n $params = $base;\n $style = $spec['style'];\n $seenCursors = [];\n $seenLinks = [];\n $offset = null;\n $page = null;\n while (true) {\n [$raw, $response] = $call($params);\n yield $raw;\n if ($style === 'cursor') {\n $next = resolvePointer($raw, $spec['nextCursor'] ?? '');\n if (isset($spec['hasMore']) && resolvePointer($raw, $spec['hasMore']) !== true) {\n return;\n }\n if (!is_string($next) || $next === '' || isset($seenCursors[$next])) {\n return;\n }\n $seenCursors[$next] = true;\n $params[$spec['param']] = $next;\n } elseif ($style === 'link') {\n $target = linkNext($response['headers']['link'] ?? null);\n if ($target === null || isset($seenLinks[$target])) {\n return;\n }\n $seenLinks[$target] = true;\n $parsed = parse_url($target);\n $linkParams = [];\n parse_str($parsed['query'] ?? '', $linkParams);\n $params = array_merge($params, $linkParams);\n } else {\n $items = resolvePointer($raw, $spec['items'] ?? '');\n $count = is_array($items) ? count($items) : 0;\n if ($count === 0) {\n return;\n }\n if ($style === 'offset') {\n $offset = ($offset ?? (int) ($base[$spec['param']] ?? 0)) + $count;\n $params[$spec['param']] = $offset;\n } else {\n $page = ($page ?? (int) ($base[$spec['param']] ?? 1)) + 1;\n $params[$spec['param']] = $page;\n }\n }\n }\n}\n\n/** Parse one SSE frame; returns `[?ServerSentEvent, ?string lastEventId, ?int retryMs]`. */\nfunction parseSseFrame(string $frame, bool $jsonData): array\n{\n $event = 'message';\n $dataLines = [];\n $id = null;\n $retry = null;\n foreach (explode(\"\\n\", str_replace(\"\\r\\n\", \"\\n\", $frame)) as $line) {\n if ($line === '' || str_starts_with($line, ':')) {\n continue;\n }\n $colon = strpos($line, ':');\n $field = $colon === false ? $line : substr($line, 0, $colon);\n $value = $colon === false ? '' : ltrim(substr($line, $colon + 1), ' ');\n if ($field === 'event') {\n $event = $value;\n } elseif ($field === 'data') {\n $dataLines[] = $value;\n } elseif ($field === 'id') {\n $id = $value;\n } elseif ($field === 'retry' && ctype_digit($value)) {\n $retry = (int) $value;\n }\n }\n if ($dataLines === [] && $id === null && $retry === null) {\n return [null, null, $retry];\n }\n $data = implode(\"\\n\", $dataLines);\n $decoded = $jsonData && $data !== '' ? json_decode($data, true) : $data;\n return [new ServerSentEvent($event, $decoded, $id, $retry), $id, $retry];\n}\n\n/**\n * Stream server-sent events. `$open(array $extraHeaders): \\CurlHandle` returns a configured\n * (not yet executed) handle; this pump drives it with curl_multi, yields parsed frames, and\n * reconnects with `Last-Event-ID` on transient failures (4xx is definitive; backoff <= 30s).\n */\nfunction iterSse(callable $open, bool $jsonData): \\Generator\n{\n $lastEventId = null;\n $retryMs = 3000;\n while (true) {\n $extra = ['Accept' => 'text/event-stream'];\n if ($lastEventId !== null) {\n $extra['Last-Event-ID'] = $lastEventId;\n }\n $handle = $open($extra);\n $buffer = '';\n curl_setopt($handle, CURLOPT_WRITEFUNCTION, function ($ch, string $chunk) use (&$buffer): int {\n $buffer .= $chunk;\n return strlen($chunk);\n });\n $multi = curl_multi_init();\n curl_multi_add_handle($multi, $handle);\n do {\n curl_multi_exec($multi, $running);\n if ($running > 0) {\n curl_multi_select($multi, 0.1);\n }\n while (($split = strpos($buffer, \"\\n\\n\")) !== false || ($split = strpos($buffer, \"\\r\\n\\r\\n\")) !== false) {\n $frameLength = $buffer[$split] === \"\\r\" ? 4 : 2;\n $frame = substr($buffer, 0, $split);\n $buffer = substr($buffer, $split + $frameLength);\n [$event, $id, $retry] = parseSseFrame($frame, $jsonData);\n if ($id !== null) {\n $lastEventId = $id;\n }\n if ($retry !== null) {\n $retryMs = min($retry, 30000);\n }\n if ($event !== null) {\n yield $event;\n }\n }\n } while ($running > 0);\n $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);\n $url = (string) curl_getinfo($handle, CURLINFO_EFFECTIVE_URL);\n curl_multi_remove_handle($multi, $handle);\n curl_multi_close($multi);\n if ($status >= 400 && $status < 500) {\n throw new ApiError($url, $status, '', $buffer);\n }\n // A clean 200 end-of-stream is done; anything else reconnects with Last-Event-ID.\n if ($status === 200) {\n return;\n }\n usleep($retryMs * 1000);\n }\n}\n\n/** Encode an assoc body as `multipart/form-data`; nested values are JSON parts. Returns `[contentType, body]`. */\nfunction toMultipart(array $body): array\n{\n $boundary = 'redocly-' . bin2hex(random_bytes(12));\n $parts = '';\n foreach ($body as $name => $value) {\n $parts .= \"--{$boundary}\\r\\n\";\n if (is_array($value)) {\n $parts .= \"Content-Disposition: form-data; name=\\\"{$name}\\\"\\r\\n\";\n $parts .= \"Content-Type: application/json\\r\\n\\r\\n\";\n $parts .= json_encode($value) . \"\\r\\n\";\n } else {\n $parts .= \"Content-Disposition: form-data; name=\\\"{$name}\\\"\\r\\n\\r\\n\";\n $parts .= (is_bool($value) ? ($value ? 'true' : 'false') : (string) $value) . \"\\r\\n\";\n }\n }\n $parts .= \"--{$boundary}--\\r\\n\";\n return ['multipart/form-data; boundary=' . $boundary, $parts];\n}\n"; + "= 8.1, zero Composer dependencies; HTTP over the curl extension.\n// The generated file re-declares the namespace; the embed strips this header.\n\ndeclare(strict_types=1);\n\nnamespace RedoclyClientRuntime;\n\n/** A response with status >= 400, decoded body attached. */\nfinal class ApiError extends \\RuntimeException\n{\n public function __construct(\n public readonly string $url,\n public readonly int $status,\n public readonly string $reason,\n public readonly mixed $body,\n ) {\n parent::__construct(\"HTTP {$status} {$reason} for {$url}\");\n }\n}\n\n/** Every attempt timed out or failed to connect. */\nfinal class TimeoutError extends \\RuntimeException\n{\n public function __construct(\n public readonly string $url,\n public readonly ?float $timeout,\n public readonly int $attempts,\n ) {\n $seconds = $timeout === null ? 'the configured timeout' : \"{$timeout}s\";\n parent::__construct(\"Request to {$url} timed out after {$seconds} ({$attempts} attempt(s))\");\n }\n}\n\n/** One parsed `text/event-stream` frame. */\nfinal class ServerSentEvent\n{\n public function __construct(\n public readonly string $event,\n public readonly mixed $data,\n public readonly ?string $id = null,\n public readonly ?int $retry = null,\n ) {\n }\n}\n\n/**\n * Per-instance configuration.\n * `auth`: `['bearer' => string|callable, 'basic' => ['username' => ..., 'password' => ...], 'apiKey' => [scheme => string|callable]]`.\n * `retry`: `['attempts' => int, 'delay' => float, 'strategy' => 'exponential'|'fixed', 'retryOn' => callable]`.\n * `middleware`: callables `fn(array $request, callable $next): array` around each attempt.\n */\nfinal class Config\n{\n public function __construct(\n public string $serverUrl = '',\n public array $auth = [],\n public ?float $timeout = null,\n public array $retry = [],\n public array $middleware = [],\n public string $clientHeader = 'redocly-client-generator',\n ) {\n }\n}\n\n/** Resolve a literal-or-callable credential to its string value. */\nfunction resolveToken(mixed $provider): string\n{\n return is_callable($provider) ? (string) $provider() : (string) $provider;\n}\n\n/**\n * Apply the first fully-configured security alternative. `$security` is an OR-list\n * of AND-sets of specs: `['kind' => 'bearer'|'basic'|'apiKey', 'scheme' => ..., 'name' => ?, 'in' => ?]`.\n * Returns `[headers, query, cookies]`.\n */\nfunction resolveAuth(array $security, array $auth): array\n{\n foreach ($security as $andSet) {\n $headers = [];\n $query = [];\n $cookies = [];\n $satisfied = true;\n foreach ($andSet as $spec) {\n if ($spec['kind'] === 'bearer' && isset($auth['bearer'])) {\n $headers['Authorization'] = 'Bearer ' . resolveToken($auth['bearer']);\n } elseif ($spec['kind'] === 'basic' && isset($auth['basic'])) {\n $headers['Authorization'] =\n 'Basic ' . base64_encode($auth['basic']['username'] . ':' . $auth['basic']['password']);\n } elseif ($spec['kind'] === 'apiKey' && isset($auth['apiKey'][$spec['scheme']])) {\n $value = resolveToken($auth['apiKey'][$spec['scheme']]);\n if ($spec['in'] === 'query') {\n $query[$spec['name']] = $value;\n } elseif ($spec['in'] === 'cookie') {\n $cookies[] = $spec['name'] . '=' . rawurlencode($value);\n } else {\n $headers[$spec['name']] = $value;\n }\n } else {\n $satisfied = false;\n break;\n }\n }\n if ($satisfied) {\n return [$headers, $query, $cookies];\n }\n }\n return [[], [], []];\n}\n\n/** Substitute `{param}` templates with encoded values and prefix the server URL. */\nfunction buildUrl(string $serverUrl, string $path, array $pathParams): string\n{\n foreach ($pathParams as $name => $value) {\n $path = str_replace('{' . $name . '}', rawurlencode((string) $value), $path);\n }\n return rtrim($serverUrl, '/') . $path;\n}\n\n/** The default retry predicate: 5xx, 429, and transport timeouts/connect failures. */\nfunction defaultRetryOn(array $context): bool\n{\n if (($context['timedOut'] ?? false) === true) {\n return true;\n }\n $status = $context['status'] ?? 0;\n return $status >= 500 || $status === 429;\n}\n\n/** Delay before the next attempt: `Retry-After` wins; otherwise jittered (fixed|exponential) backoff. */\nfunction retryDelay(int $attempt, array $retry, ?string $retryAfter): float\n{\n if ($retryAfter !== null && ctype_digit($retryAfter)) {\n return (float) $retryAfter;\n }\n $base = (float) ($retry['delay'] ?? 1.0);\n $strategy = $retry['strategy'] ?? 'exponential';\n $delay = $strategy === 'fixed' ? $base : $base * (2 ** ($attempt - 1));\n return $delay * (0.5 + mt_rand() / mt_getrandmax() / 2);\n}\n\n/** Append query params in form style: list values repeat the key (`tag=a&tag=b`). */\nfunction appendQuery(string $url, array $query): string\n{\n $pairs = [];\n foreach ($query as $name => $value) {\n foreach (is_array($value) ? $value : [$value] as $single) {\n $encoded = is_bool($single) ? ($single ? 'true' : 'false') : (string) $single;\n $pairs[] = rawurlencode($name) . '=' . rawurlencode($encoded);\n }\n }\n if ($pairs === []) {\n return $url;\n }\n return $url . (str_contains($url, '?') ? '&' : '?') . implode('&', $pairs);\n}\n\n/** One raw curl exchange. Returns `['status', 'reason', 'headers', 'body', 'url', 'timedOut']`. */\nfunction rawSend(Config $config, array $request): array\n{\n $url = appendQuery($request['url'], $request['query'] ?? []);\n $handle = curl_init($url);\n $headerLines = [];\n foreach ($request['headers'] ?? [] as $name => $value) {\n $headerLines[] = $name . ': ' . $value;\n }\n $responseHeaders = [];\n curl_setopt_array($handle, [\n CURLOPT_CUSTOMREQUEST => $request['method'],\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_HTTPHEADER => $headerLines,\n CURLOPT_HEADERFUNCTION => function ($ch, string $line) use (&$responseHeaders): int {\n $parts = explode(':', $line, 2);\n if (count($parts) === 2) {\n $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);\n }\n return strlen($line);\n },\n ]);\n if (($request['body'] ?? null) !== null) {\n curl_setopt($handle, CURLOPT_POSTFIELDS, $request['body']);\n }\n if ($config->timeout !== null) {\n curl_setopt($handle, CURLOPT_TIMEOUT_MS, (int) round($config->timeout * 1000));\n }\n $body = curl_exec($handle);\n $errno = curl_errno($handle);\n $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);\n $effectiveUrl = (string) curl_getinfo($handle, CURLINFO_EFFECTIVE_URL);\n curl_close($handle);\n if ($errno !== 0) {\n $timedOut = $errno === CURLE_OPERATION_TIMEDOUT || $errno === CURLE_COULDNT_CONNECT;\n return [\n 'status' => 0,\n 'reason' => curl_strerror($errno) ?? 'transport error',\n 'headers' => [],\n 'body' => '',\n 'url' => $effectiveUrl,\n 'timedOut' => $timedOut,\n ];\n }\n return [\n 'status' => $status,\n 'reason' => '',\n 'headers' => $responseHeaders,\n 'body' => is_string($body) ? $body : '',\n 'url' => $effectiveUrl,\n 'timedOut' => false,\n ];\n}\n\n/**\n * Send with retries and middleware. `$request` carries `operationId`, `method`, `url`,\n * `headers`, `query`, and optional `body`/`contentType`/`idempotencyKey`.\n * Returns the raw response array; callers map status >= 400 to `ApiError`.\n */\nfunction send(Config $config, array $request): array\n{\n $headers = $request['headers'] ?? [];\n $headers['X-Redocly-Client'] = $config->clientHeader;\n if (($request['contentType'] ?? null) !== null) {\n $headers['Content-Type'] = $request['contentType'];\n }\n if (($request['idempotencyKey'] ?? null) !== null) {\n $headers['Idempotency-Key'] = $request['idempotencyKey'];\n }\n $request['headers'] = $headers;\n\n $handler = fn (array $req): array => rawSend($config, $req);\n foreach (array_reverse($config->middleware) as $middleware) {\n $next = $handler;\n $handler = fn (array $req): array => $middleware($req, $next);\n }\n\n $attempts = max(1, (int) ($config->retry['attempts'] ?? 3));\n $retryOn = $config->retry['retryOn'] ?? __NAMESPACE__ . '\\\\defaultRetryOn';\n $response = null;\n for ($attempt = 1; $attempt <= $attempts; $attempt++) {\n $response = $handler($request);\n $context = [\n 'status' => $response['status'],\n 'timedOut' => $response['timedOut'],\n 'attempt' => $attempt,\n 'operationId' => $request['operationId'] ?? '',\n ];\n if ($attempt === $attempts || !$retryOn($context)) {\n break;\n }\n $seconds = retryDelay($attempt, $config->retry, $response['headers']['retry-after'] ?? null);\n usleep((int) round($seconds * 1_000_000));\n }\n if ($response['timedOut']) {\n throw new TimeoutError($response['url'], $config->timeout, $attempts);\n }\n if ($response['status'] === 0) {\n throw new \\RuntimeException(\"Request to {$response['url']} failed: {$response['reason']}\");\n }\n return $response;\n}\n\n/** Decoded JSON body (assoc arrays), or null for empty bodies. */\nfunction decodeJson(array $response): mixed\n{\n if ($response['body'] === '') {\n return null;\n }\n return json_decode($response['body'], true);\n}\n\n/** `ApiError` from a non-2xx response. */\nfunction apiErrorFrom(array $response): ApiError\n{\n return new ApiError($response['url'], $response['status'], $response['reason'], decodeJson($response));\n}\n\n/** Walk an RFC 6901 JSON pointer over decoded JSON; null on any miss. */\nfunction resolvePointer(mixed $data, string $pointer): mixed\n{\n if ($pointer === '') {\n return $data;\n }\n foreach (explode('/', substr($pointer, 1)) as $token) {\n $key = str_replace(['~1', '~0'], ['/', '~'], $token);\n if (!is_array($data) || !array_key_exists($key, $data)) {\n return null;\n }\n $data = $data[$key];\n }\n return $data;\n}\n\n/** The `rel=\"next\"` target of a `Link` header, or null. */\nfunction linkNext(?string $header): ?string\n{\n if ($header === null) {\n return null;\n }\n foreach (explode(',', $header) as $part) {\n if (preg_match('/<([^>]+)>\\s*;[^,]*rel=\"?next\"?/', trim($part), $match) === 1) {\n return $match[1];\n }\n }\n return null;\n}\n\n/**\n * Auto-pagination: `$call(array $params): [mixed rawPage, array $response]`, `$spec` is the\n * normalized rule (`style`, `param`, `nextCursor`, `hasMore`, `items`), `$base` the caller's\n * query params. Yields raw decoded pages; generated wrappers hydrate them into models.\n */\nfunction iterPages(callable $call, array $spec, array $base): \\Generator\n{\n $params = $base;\n $style = $spec['style'];\n $seenCursors = [];\n $seenLinks = [];\n $offset = null;\n $page = null;\n while (true) {\n [$raw, $response] = $call($params);\n yield $raw;\n if ($style === 'cursor') {\n $next = resolvePointer($raw, $spec['nextCursor'] ?? '');\n if (isset($spec['hasMore']) && resolvePointer($raw, $spec['hasMore']) !== true) {\n return;\n }\n if (!is_string($next) || $next === '' || isset($seenCursors[$next])) {\n return;\n }\n $seenCursors[$next] = true;\n $params[$spec['param']] = $next;\n } elseif ($style === 'link') {\n $target = linkNext($response['headers']['link'] ?? null);\n if ($target === null || isset($seenLinks[$target])) {\n return;\n }\n $seenLinks[$target] = true;\n $parsed = parse_url($target);\n $linkParams = [];\n parse_str($parsed['query'] ?? '', $linkParams);\n $params = array_merge($params, $linkParams);\n } else {\n $items = resolvePointer($raw, $spec['items'] ?? '');\n $count = is_array($items) ? count($items) : 0;\n if ($count === 0) {\n return;\n }\n if ($style === 'offset') {\n $offset = ($offset ?? (int) ($base[$spec['param']] ?? 0)) + $count;\n $params[$spec['param']] = $offset;\n } else {\n $page = ($page ?? (int) ($base[$spec['param']] ?? 1)) + 1;\n $params[$spec['param']] = $page;\n }\n }\n }\n}\n\n/** Parse one SSE frame; returns `[?ServerSentEvent, ?string lastEventId, ?int retryMs]`. */\nfunction parseSseFrame(string $frame, bool $jsonData): array\n{\n $event = 'message';\n $dataLines = [];\n $id = null;\n $retry = null;\n foreach (explode(\"\\n\", str_replace(\"\\r\\n\", \"\\n\", $frame)) as $line) {\n if ($line === '' || str_starts_with($line, ':')) {\n continue;\n }\n $colon = strpos($line, ':');\n $field = $colon === false ? $line : substr($line, 0, $colon);\n $value = $colon === false ? '' : ltrim(substr($line, $colon + 1), ' ');\n if ($field === 'event') {\n $event = $value;\n } elseif ($field === 'data') {\n $dataLines[] = $value;\n } elseif ($field === 'id') {\n $id = $value;\n } elseif ($field === 'retry' && ctype_digit($value)) {\n $retry = (int) $value;\n }\n }\n if ($dataLines === [] && $id === null && $retry === null) {\n return [null, null, $retry];\n }\n $data = implode(\"\\n\", $dataLines);\n $decoded = $jsonData && $data !== '' ? json_decode($data, true) : $data;\n return [new ServerSentEvent($event, $decoded, $id, $retry), $id, $retry];\n}\n\n/**\n * Stream server-sent events. `$open(array $extraHeaders): \\CurlHandle` returns a configured\n * (not yet executed) handle; this pump drives it with curl_multi, yields parsed frames, and\n * reconnects with `Last-Event-ID` on transient failures (4xx is definitive; backoff <= 30s).\n */\nfunction iterSse(callable $open, bool $jsonData): \\Generator\n{\n $lastEventId = null;\n $retryMs = 3000;\n while (true) {\n $extra = ['Accept' => 'text/event-stream'];\n if ($lastEventId !== null) {\n $extra['Last-Event-ID'] = $lastEventId;\n }\n $handle = $open($extra);\n $buffer = '';\n curl_setopt($handle, CURLOPT_WRITEFUNCTION, function ($ch, string $chunk) use (&$buffer): int {\n $buffer .= $chunk;\n return strlen($chunk);\n });\n $multi = curl_multi_init();\n curl_multi_add_handle($multi, $handle);\n do {\n curl_multi_exec($multi, $running);\n if ($running > 0) {\n curl_multi_select($multi, 0.1);\n }\n while (($split = strpos($buffer, \"\\n\\n\")) !== false || ($split = strpos($buffer, \"\\r\\n\\r\\n\")) !== false) {\n $frameLength = $buffer[$split] === \"\\r\" ? 4 : 2;\n $frame = substr($buffer, 0, $split);\n $buffer = substr($buffer, $split + $frameLength);\n [$event, $id, $retry] = parseSseFrame($frame, $jsonData);\n if ($id !== null) {\n $lastEventId = $id;\n }\n if ($retry !== null) {\n $retryMs = min($retry, 30000);\n }\n if ($event !== null) {\n yield $event;\n }\n }\n } while ($running > 0);\n $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);\n $url = (string) curl_getinfo($handle, CURLINFO_EFFECTIVE_URL);\n curl_multi_remove_handle($multi, $handle);\n curl_multi_close($multi);\n if ($status >= 400 && $status < 500) {\n throw new ApiError($url, $status, '', $buffer);\n }\n // A clean 200 end-of-stream is done; anything else reconnects with Last-Event-ID.\n if ($status === 200) {\n return;\n }\n usleep($retryMs * 1000);\n }\n}\n\n/** Encode an assoc body as `multipart/form-data`; nested values are JSON parts. Returns `[contentType, body]`. */\nfunction toMultipart(array $body): array\n{\n $boundary = 'redocly-' . bin2hex(random_bytes(12));\n $parts = '';\n foreach ($body as $name => $value) {\n $parts .= \"--{$boundary}\\r\\n\";\n if (is_array($value)) {\n $parts .= \"Content-Disposition: form-data; name=\\\"{$name}\\\"\\r\\n\";\n $parts .= \"Content-Type: application/json\\r\\n\\r\\n\";\n $parts .= json_encode($value) . \"\\r\\n\";\n } else {\n $parts .= \"Content-Disposition: form-data; name=\\\"{$name}\\\"\\r\\n\\r\\n\";\n $parts .= (is_bool($value) ? ($value ? 'true' : 'false') : (string) $value) . \"\\r\\n\";\n }\n }\n $parts .= \"--{$boundary}--\\r\\n\";\n return ['multipart/form-data; boundary=' . $boundary, $parts];\n}\n"; diff --git a/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts b/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts index 780e46ed77..a959b75837 100644 --- a/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts +++ b/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts @@ -11,11 +11,12 @@ const ALLOWED_SPECIFIERS = new Set([ '../authoring/index.js', '../emitters/python-runtime-sources.js', // pure embedded strings, generated at prepare time '../emitters/go-runtime-sources.js', + '../emitters/php-runtime-sources.js', '../intermediate-representation/model.js', // type-only IR shapes './types.js', // the generator contract ]); -describe.each(['python.ts', 'go.ts'])('%s dogfooding invariant', (file) => { +describe.each(['python.ts', 'go.ts', 'php.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), diff --git a/packages/client-generator/src/generators/__tests__/php.test.ts b/packages/client-generator/src/generators/__tests__/php.test.ts new file mode 100644 index 0000000000..626c413ee3 --- /dev/null +++ b/packages/client-generator/src/generators/__tests__/php.test.ts @@ -0,0 +1,355 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js'; +import { phpGenerator, renderPhpModels } from '../php.js'; + +const hasPhp = spawnSync('php', ['--version']).status === 0; + +/** Assert the rendered source parses AND declares cleanly (php -l, then require). */ +function expectPhpRuns(source: string): void { + if (!hasPhp) return; + const dir = mkdtempSync(join(tmpdir(), 'php-render-')); + try { + writeFileSync(join(dir, 'client.php'), source); + const lint = spawnSync('php', ['-l', 'client.php'], { cwd: dir, encoding: 'utf-8' }); + expect(lint.status, `${lint.stdout}\n${lint.stderr}`).toBe(0); + const declare = spawnSync('php', ['-r', "require 'client.php'; echo 'DECLARED';"], { + cwd: dir, + encoding: 'utf-8', + }); + expect(declare.status, `${declare.stdout}\n${declare.stderr}`).toBe(0); + expect(declare.stdout).toContain('DECLARED'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +/** Models-only sources still need the file header to parse standalone. */ +function expectModelsRun(models: string): void { + expectPhpRuns(`): ApiModel { + return { + title: 'Cafe', + version: '1.0.0', + services: [], + schemas: Object.entries(schemas).map(([name, schema]) => ({ name, schema })), + securitySchemes: [], + } as unknown as ApiModel; +} + +describe('renderPhpModels', () => { + it('renders classes — required first, optionals nullable with defaults, wire maps preserved', () => { + const out = renderPhpModels( + model({ + Order: { + kind: 'object', + description: 'One placed order.', + properties: [ + { name: 'id', schema: STRING, required: true }, + { name: 'quantity', schema: INT, required: true }, + { name: 'special-note', schema: STRING, required: false }, + ], + }, + }) + ); + expect(out).toContain('final class Order'); + expect(out).toContain('public string $id'); + expect(out).toContain('public int $quantity'); + expect(out).toContain('public ?string $specialNote = null'); + expect(out).toContain("$data['special-note']"); // wire name survives in the field map + expect(out).toContain('public static function fromArray(array $data): self'); + expect(out).toContain('public function toArray(): array'); + expectModelsRun(out); + }); + + it('flattens allOf and hydrates nested refs, arrays of refs, and enums', () => { + const out = renderPhpModels( + model({ + Base: { kind: 'object', properties: [{ name: 'offset', schema: INT, required: false }] }, + Status: { kind: 'enum', values: ['in-progress', 'done'], scalar: 'string' }, + Order: { + kind: 'object', + properties: [{ name: 'status', schema: { kind: 'ref', name: 'Status' }, required: true }], + }, + Page: { + kind: 'intersection', + members: [ + { kind: 'ref', name: 'Base' }, + { + kind: 'object', + properties: [ + { + name: 'items', + schema: { kind: 'array', items: { kind: 'ref', name: 'Order' } }, + required: true, + }, + ], + }, + ], + }, + }) + ); + expect(out).toContain('final class Page'); + expect(out).toContain('enum Status: string'); + expect(out).toContain("case InProgress = 'in-progress';"); + expect(out).toContain("Status::from($data['status'])"); + expect(out).toContain( + "array_map(static fn ($item) => Order::fromArray($item), $data['items'])" + ); + expectModelsRun(out); + }); + + it('renders discriminated unions as match dispatchers and keeps +1/-1 distinct', () => { + const out = renderPhpModels( + model({ + Cat: { kind: 'object', properties: [] }, + Dog: { kind: 'object', properties: [] }, + Pet: { + kind: 'union', + members: [ + { kind: 'ref', name: 'Cat' }, + { kind: 'ref', name: 'Dog' }, + ], + discriminator: { + propertyName: 'petType', + mapping: [ + { value: 'cat', schemaName: 'Cat' }, + { value: 'dog', schemaName: 'Dog' }, + ], + }, + }, + Reactions: { + kind: 'object', + properties: [ + { name: '+1', schema: INT, required: true }, + { name: '-1', schema: INT, required: true }, + ], + }, + }) + ); + expect(out).toContain('function unmarshalPet(array $data): mixed'); + expect(out).toContain("'cat' => Cat::fromArray($data)"); + expect(out).toContain('public int $plus1'); + expect(out).toContain('public int $minus1'); + expectModelsRun(out); + }); + + it('maps nullability and reserved names idiomatically', () => { + const out = renderPhpModels( + model({ + Lesson: { + kind: 'object', + properties: [ + { + name: 'tag', + schema: { kind: 'union', members: [STRING, { kind: 'null' }] }, + required: true, + }, + { name: 'class', schema: STRING, required: true }, + ], + }, + }) + ); + expect(out).toContain('public ?string $tag'); + expect(out).toContain('public string $class_'); + expect(out).toContain("$data['class']"); + expectModelsRun(out); + }); +}); + +const CAFE: ApiModel = { + title: 'Cafe Orders API', + version: '1.0.0', + serverUrl: 'https://api.cafe.example', + services: [ + { + name: 'Orders', + operations: [ + { + name: 'listOrders', + specName: 'listOrders', + method: 'get', + path: '/orders', + tags: ['Orders'], + pathParams: [], + queryParams: [ + { name: 'after', in: 'query', required: false, schema: STRING }, + { name: 'limit', in: 'query', required: false, schema: INT }, + ], + headerParams: [], + cookieParams: [], + security: [['BearerAuth']], + paginationExtension: { + style: 'cursor', + cursorParam: 'after', + nextCursor: '/next', + items: '/items', + }, + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'ref', name: 'OrderPage' }, + }, + ], + errorResponses: [], + }, + { + name: 'getOrder', + specName: 'getOrder', + method: 'get', + path: '/orders/{orderId}', + tags: ['Orders'], + pathParams: [{ name: 'orderId', in: 'path', required: true, schema: STRING }], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'ref', name: 'Order' }, + }, + ], + errorResponses: [], + }, + { + name: 'createOrder', + specName: 'createOrder', + method: 'post', + path: '/orders', + tags: ['Orders'], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + requestBody: { + contentType: 'application/json', + required: true, + schema: { kind: 'ref', name: 'Order' }, + }, + successResponses: [ + { + status: '201', + contentType: 'application/json', + schema: { kind: 'ref', name: 'Order' }, + }, + ], + errorResponses: [], + }, + { + name: 'streamEvents', + specName: 'streamEvents', + method: 'get', + path: '/events', + tags: ['Orders'], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { + status: '200', + contentType: 'text/event-stream', + schema: { kind: 'object', properties: [] }, + }, + ], + errorResponses: [], + }, + { + name: 'uploadPhoto', + specName: 'uploadPhoto', + method: 'post', + path: '/photos', + tags: ['Orders'], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + requestBody: { + contentType: 'multipart/form-data', + required: true, + schema: { kind: 'object', properties: [] }, + }, + successResponses: [{ status: '204', contentType: '', schema: { kind: 'unknown' } }], + errorResponses: [], + }, + ], + }, + ], + schemas: [ + { + name: 'Order', + schema: { kind: 'object', properties: [{ name: 'id', schema: STRING, required: true }] }, + }, + { + name: 'OrderPage', + schema: { + kind: 'object', + properties: [ + { + name: 'items', + schema: { kind: 'array', items: { kind: 'ref', name: 'Order' } }, + required: true, + }, + { name: 'next', schema: STRING, required: false }, + ], + }, + }, + ], + securitySchemes: [{ key: 'BearerAuth', kind: 'bearer' }], +} as unknown as ApiModel; + +function generatePhp(): string { + const files = phpGenerator({ + model: CAFE, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: {}, + }); + expect(files).toHaveLength(1); + expect(files[0].path).toBe('/out/client.php'); + return files[0].content; +} + +describe('phpGenerator (full client assembly)', () => { + it('assembles one runnable file: namespace, models, embedded runtime, operations, Client', () => { + const out = generatePhp(); + expect(out.startsWith(' { + const out = generatePhp(); + expect(out).toContain('public function listOrdersPages('); + expect(out).toContain('public function listOrdersItems('); + expect(out).toContain('iterPages($call,'); + expect(out).toContain('yield OrderPage::fromArray($page);'); + expect(out).toContain('public function streamEvents(?array $headers = null): \\Generator'); + expect(out).toContain('yield from iterSse($open,'); + expect(out).toContain('toMultipart($body)'); + expectPhpRuns(out); + }); +}); diff --git a/packages/client-generator/src/generators/index.ts b/packages/client-generator/src/generators/index.ts index b40be0fa62..bfc2d0924f 100644 --- a/packages/client-generator/src/generators/index.ts +++ b/packages/client-generator/src/generators/index.ts @@ -3,6 +3,7 @@ import { cliGenerator, cliSample } from './cli.js'; import { goGenerator, goSample } from './go.js'; import { BUILTIN_META, validateSelection, type BuiltinMeta } from './meta.js'; import { mockGenerator } from './mock.js'; +import { phpGenerator, phpSample } from './php.js'; import { pythonGenerator, pythonSample } from './python.js'; import { sdkGenerator, sdkSample } from './sdk.js'; import { swrGenerator } from './swr.js'; @@ -36,6 +37,7 @@ const RUNS: Record> = cli: { run: cliGenerator, sample: cliSample }, python: { run: pythonGenerator, sample: pythonSample }, go: { run: goGenerator, sample: goSample }, + php: { run: phpGenerator, sample: phpSample }, }; const GENERATORS = Object.fromEntries( diff --git a/packages/client-generator/src/generators/meta.ts b/packages/client-generator/src/generators/meta.ts index 5b58b4bff1..830aca9306 100644 --- a/packages/client-generator/src/generators/meta.ts +++ b/packages/client-generator/src/generators/meta.ts @@ -68,6 +68,10 @@ export const BUILTIN_META: Record = { go: { load: () => import('./go.js').then((m) => ({ run: m.goGenerator, sample: m.goSample })), }, + // php emits a standalone full PHP SDK (curl extension) — no TypeScript involved. + php: { + load: () => import('./php.js').then((m) => ({ run: m.phpGenerator, sample: m.phpSample })), + }, }; /** diff --git a/packages/client-generator/src/generators/php.ts b/packages/client-generator/src/generators/php.ts new file mode 100644 index 0000000000..e55141f235 --- /dev/null +++ b/packages/client-generator/src/generators/php.ts @@ -0,0 +1,769 @@ +// 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 { + CodeWriter, + docText, + discriminatorCases, + enumValues, + flattenAllOf, + identifierFor, + isNullable, + paginationRuleFor, + RESERVED_WORDS, + schemaAtPointer, + unwrapNullable, + type NeutralPaginationRule, +} from '../authoring/index.js'; +import { PHP_RUNTIME_SOURCE } from '../emitters/php-runtime-sources.js'; +import type { + ApiModel, + OperationModel, + PropertyModel, + SchemaModel, +} from '../intermediate-representation/model.js'; +import type { CodeSample, Generator, SampleContext } from './types.js'; + +const PHP = RESERVED_WORDS.php; + +function className(name: string): string { + return identifierFor(name, { style: 'pascal', reserved: PHP }); +} + +function propertyName(name: string): string { + return identifierFor(name, { style: 'camel', reserved: PHP }); +} + +/** `'…'` with backslashes and quotes escaped — safe for any spec-supplied text. */ +function phpString(value: string): string { + return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`; +} + +/** Follow ref chains through the named schemas (cycle-guarded). */ +function deref(schema: SchemaModel, model: ApiModel): SchemaModel | undefined { + const seen = new Set(); + let current = schema; + while (current.kind === 'ref') { + const { name } = current; + if (seen.has(name)) return undefined; + seen.add(name); + const named = model.schemas.find((candidate) => candidate.name === name); + if (named === undefined) return undefined; + current = named.schema; + } + return current; +} + +/** What a named schema renders as: a class, a native enum, or nothing (alias). */ +function classify(name: string, model: ApiModel): 'class' | 'enum' | 'other' { + const named = model.schemas.find((candidate) => candidate.name === name); + if (named === undefined) return 'other'; + const schema = named.schema; + const asEnum = enumValues(schema); + if (asEnum !== undefined && (asEnum.scalar === 'string' || asEnum.scalar === 'integer')) { + return 'enum'; + } + if ( + (schema.kind === 'object' || schema.kind === 'intersection') && + flattenAllOf(schema, model) !== undefined + ) { + return 'class'; + } + return 'other'; +} + +/** The PHP type declaration for a schema (arrays and unions widen to array/mixed). */ +export function phpType(schema: SchemaModel, model: ApiModel): string { + 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: SchemaModel, expr: string, model: ApiModel): string | undefined { + const bare = unwrapNullable(schema); + 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: SchemaModel, expr: string, model: ApiModel): string | undefined { + const bare = unwrapNullable(schema); + 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: CodeWriter, name: string, description?: string): void { + const lines = docText(description); + if (lines.length === 0) return; + writer.line(`/** ${name} — ${lines.join(' ')} */`); +} + +function writeClass( + writer: CodeWriter, + name: string, + properties: PropertyModel[], + model: ApiModel, + description?: string +): void { + // 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: ApiModel): string { + const writer = new CodeWriter(' '); + 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: OperationModel): SchemaModel | undefined { + return op.successResponses.find((response) => response.contentType.toLowerCase().includes('json')) + ?.schema; +} + +function sseResponse(op: OperationModel) { + return op.successResponses.find((response) => + response.contentType.toLowerCase().includes('text/event-stream') + ); +} + +function isMultipart(op: OperationModel): boolean { + return op.requestBody?.contentType.toLowerCase().includes('multipart') ?? false; +} + +function methodName(op: OperationModel): string { + return identifierFor(op.name, { style: 'camel', reserved: PHP }); +} + +const MUTATING = new Set(['post', 'put', 'patch']); + +/** Security literal for the operations table, denormalized from the model's schemes. */ +function phpSecurityLiteral(op: OperationModel, model: ApiModel): string | undefined { + if (op.security.length === 0) return undefined; + const alternatives = op.security.map((andSet) => { + const specs = andSet.flatMap((key): string[] => { + const scheme = model.securitySchemes.find((candidate) => candidate.key === key); + if (scheme === undefined) return []; + if (scheme.kind === 'bearer' || scheme.kind === 'basic') { + return [`['kind' => ${phpString(scheme.kind)}, 'scheme' => ${phpString(scheme.key)}]`]; + } + const where = + scheme.kind === 'apiKeyQuery' + ? 'query' + : scheme.kind === 'apiKeyCookie' + ? 'cookie' + : 'header'; + const name = + scheme.kind === 'apiKeyQuery' + ? scheme.paramName + : scheme.kind === 'apiKeyCookie' + ? scheme.cookieName + : scheme.headerName; + return [ + `['kind' => 'apiKey', 'scheme' => ${phpString(scheme.key)}, 'name' => ${phpString(name)}, 'in' => ${phpString(where)}]`, + ]; + }); + return `[${specs.join(', ')}]`; + }); + return `[${alternatives.join(', ')}]`; +} + +function phpPaginationLiteral(rule: NeutralPaginationRule): string { + const fields = [ + `'style' => ${phpString(rule.style)}`, + ...(rule.param !== undefined ? [`'param' => ${phpString(rule.param)}`] : []), + ...(rule.nextCursor !== undefined ? [`'nextCursor' => ${phpString(rule.nextCursor)}`] : []), + ...(rule.hasMore !== undefined ? [`'hasMore' => ${phpString(rule.hasMore)}`] : []), + ...(rule.limitParam !== undefined ? [`'limitParam' => ${phpString(rule.limitParam)}`] : []), + ...(rule.items !== undefined ? [`'items' => ${phpString(rule.items)}`] : []), + ]; + return `[${fields.join(', ')}]`; +} + +type MethodArgs = { + pathArgs: Array<{ php: string; wire: string; type: string }>; + queryArgs: Array<{ php: string; wire: string; type: string }>; + signature: string[]; +}; + +function methodArgs(op: OperationModel, model: ApiModel, includeBody: boolean): MethodArgs { + 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: CodeWriter, op: OperationModel, args: MethodArgs): void { + 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: CodeWriter, op: OperationModel, model: ApiModel): void { + 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: CodeWriter, + op: OperationModel, + model: ApiModel, + pageHydration: string | undefined, + itemHydration: string | undefined, + itemsPointer: string | undefined +): void { + 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 CodeWriter(' '); + const namespace = identifierFor(model.title, { style: 'pascal', reserved: PHP }); + writer.line('= 8.1, curl extension — zero Composer dependencies.' + ); + 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 as Record | undefined); + if (rule !== undefined) paginationRules.set(op.name, rule); + } + + writer.block( + 'const OPERATIONS = [', + () => { + for (const op of operations) { + const id = op.specName ?? op.name; + const security = phpSecurityLiteral(op, model); + const rule = paginationRules.get(op.name); + const fields = [ + `'id' => ${phpString(id)}`, + `'method' => ${phpString(op.method.toUpperCase())}`, + `'path' => ${phpString(op.path)}`, + ...(security !== undefined ? [`'security' => ${security}`] : []), + ...(rule !== undefined ? [`'pagination' => ${phpPaginationLiteral(rule)}`] : []), + ]; + writer.line(`${phpString(id)} => [${fields.join(', ')}],`); + } + }, + '];' + ); + writer.blank(); + + writeDocComment(writer, 'Client', `Client for ${model.title} (${model.version}).`); + writer.block('final class Client', () => {}, ''); + writer.block( + '{', + () => { + writer.block('public function __construct(private Config $config)', () => {}, ''); + writer.block( + '{', + () => { + writer.block( + "if ($this->config->serverUrl === '') {", + () => { + writer.line(`$this->config->serverUrl = ${phpString(model.serverUrl ?? '')};`); + }, + '}' + ); + }, + '}' + ); + writer.blank(); + + for (const op of operations) { + writePhpMethod(writer, op, model); + const rule = paginationRules.get(op.name); + if (rule === undefined) continue; + const success = successSchema(op); + const pageHydration = + success === undefined ? undefined : hydration(success, '$page', model); + // Resolve the items ARRAY, then take its raw element, so a `ref` element + // keeps its class name (a deref'd result would hydrate as plain data). + const itemsArray = + success !== undefined && rule.items !== undefined + ? schemaAtPointer(success, rule.items, model) + : undefined; + const element = itemsArray?.kind === 'array' ? itemsArray.items : undefined; + const itemHydration = + element === undefined ? undefined : hydration(element, '$item', model); + writePhpPaginationWrappers(writer, op, model, pageHydration, itemHydration, rule.items); + } + }, + '}' + ); + + return [{ path: outputPath.replace(/\.[^.\\/]+$/, '.php'), content: writer.toString() }]; +}; + +/** One idiomatic PHP call per operation — feeds `x-codeSamples` for docs. */ +export function phpSample(op: 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 }); + 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`, + }; +} diff --git a/packages/client-generator/src/generators/types.ts b/packages/client-generator/src/generators/types.ts index b9e1eaabd8..ce7de44694 100644 --- a/packages/client-generator/src/generators/types.ts +++ b/packages/client-generator/src/generators/types.ts @@ -29,7 +29,8 @@ export type GeneratorName = | 'mock' | 'cli' | 'python' - | 'go'; + | 'go' + | 'php'; /** Everything a generator needs to produce its files. */ export type GeneratorInput = { From 696cd4a7279678162244e11d344941e251c6ec42 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 09:39:53 +0300 Subject: [PATCH 035/211] test(client-generator): PHP e2e smoke and harness bar --- .../client-generator/php-runtime/runtime.php | 1 - .../src/emitters/php-runtime-sources.ts | 2 +- .../client-generator/src/generators/php.ts | 2 + .../generate-client/php-consumer/.gitignore | 1 + .../generate-client/php-consumer/smoke.php | 40 +++++++++++++ tests/e2e/generate-client/php.test.ts | 60 +++++++++++++++++++ .../harness/generate-client/github.harness.ts | 6 ++ tests/harness/generate-client/helpers.ts | 13 ++++ .../generate-client/rebilly.harness.ts | 15 ++++- 9 files changed, 137 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/generate-client/php-consumer/.gitignore create mode 100644 tests/e2e/generate-client/php-consumer/smoke.php create mode 100644 tests/e2e/generate-client/php.test.ts diff --git a/packages/client-generator/php-runtime/runtime.php b/packages/client-generator/php-runtime/runtime.php index 713af714a9..1f2f704f76 100644 --- a/packages/client-generator/php-runtime/runtime.php +++ b/packages/client-generator/php-runtime/runtime.php @@ -189,7 +189,6 @@ function rawSend(Config $config, array $request): array $errno = curl_errno($handle); $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE); $effectiveUrl = (string) curl_getinfo($handle, CURLINFO_EFFECTIVE_URL); - curl_close($handle); if ($errno !== 0) { $timedOut = $errno === CURLE_OPERATION_TIMEDOUT || $errno === CURLE_COULDNT_CONNECT; return [ diff --git a/packages/client-generator/src/emitters/php-runtime-sources.ts b/packages/client-generator/src/emitters/php-runtime-sources.ts index 02597c46c1..c46816b432 100644 --- a/packages/client-generator/src/emitters/php-runtime-sources.ts +++ b/packages/client-generator/src/emitters/php-runtime-sources.ts @@ -1,3 +1,3 @@ // GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`. export const PHP_RUNTIME_SOURCE = - "= 8.1, zero Composer dependencies; HTTP over the curl extension.\n// The generated file re-declares the namespace; the embed strips this header.\n\ndeclare(strict_types=1);\n\nnamespace RedoclyClientRuntime;\n\n/** A response with status >= 400, decoded body attached. */\nfinal class ApiError extends \\RuntimeException\n{\n public function __construct(\n public readonly string $url,\n public readonly int $status,\n public readonly string $reason,\n public readonly mixed $body,\n ) {\n parent::__construct(\"HTTP {$status} {$reason} for {$url}\");\n }\n}\n\n/** Every attempt timed out or failed to connect. */\nfinal class TimeoutError extends \\RuntimeException\n{\n public function __construct(\n public readonly string $url,\n public readonly ?float $timeout,\n public readonly int $attempts,\n ) {\n $seconds = $timeout === null ? 'the configured timeout' : \"{$timeout}s\";\n parent::__construct(\"Request to {$url} timed out after {$seconds} ({$attempts} attempt(s))\");\n }\n}\n\n/** One parsed `text/event-stream` frame. */\nfinal class ServerSentEvent\n{\n public function __construct(\n public readonly string $event,\n public readonly mixed $data,\n public readonly ?string $id = null,\n public readonly ?int $retry = null,\n ) {\n }\n}\n\n/**\n * Per-instance configuration.\n * `auth`: `['bearer' => string|callable, 'basic' => ['username' => ..., 'password' => ...], 'apiKey' => [scheme => string|callable]]`.\n * `retry`: `['attempts' => int, 'delay' => float, 'strategy' => 'exponential'|'fixed', 'retryOn' => callable]`.\n * `middleware`: callables `fn(array $request, callable $next): array` around each attempt.\n */\nfinal class Config\n{\n public function __construct(\n public string $serverUrl = '',\n public array $auth = [],\n public ?float $timeout = null,\n public array $retry = [],\n public array $middleware = [],\n public string $clientHeader = 'redocly-client-generator',\n ) {\n }\n}\n\n/** Resolve a literal-or-callable credential to its string value. */\nfunction resolveToken(mixed $provider): string\n{\n return is_callable($provider) ? (string) $provider() : (string) $provider;\n}\n\n/**\n * Apply the first fully-configured security alternative. `$security` is an OR-list\n * of AND-sets of specs: `['kind' => 'bearer'|'basic'|'apiKey', 'scheme' => ..., 'name' => ?, 'in' => ?]`.\n * Returns `[headers, query, cookies]`.\n */\nfunction resolveAuth(array $security, array $auth): array\n{\n foreach ($security as $andSet) {\n $headers = [];\n $query = [];\n $cookies = [];\n $satisfied = true;\n foreach ($andSet as $spec) {\n if ($spec['kind'] === 'bearer' && isset($auth['bearer'])) {\n $headers['Authorization'] = 'Bearer ' . resolveToken($auth['bearer']);\n } elseif ($spec['kind'] === 'basic' && isset($auth['basic'])) {\n $headers['Authorization'] =\n 'Basic ' . base64_encode($auth['basic']['username'] . ':' . $auth['basic']['password']);\n } elseif ($spec['kind'] === 'apiKey' && isset($auth['apiKey'][$spec['scheme']])) {\n $value = resolveToken($auth['apiKey'][$spec['scheme']]);\n if ($spec['in'] === 'query') {\n $query[$spec['name']] = $value;\n } elseif ($spec['in'] === 'cookie') {\n $cookies[] = $spec['name'] . '=' . rawurlencode($value);\n } else {\n $headers[$spec['name']] = $value;\n }\n } else {\n $satisfied = false;\n break;\n }\n }\n if ($satisfied) {\n return [$headers, $query, $cookies];\n }\n }\n return [[], [], []];\n}\n\n/** Substitute `{param}` templates with encoded values and prefix the server URL. */\nfunction buildUrl(string $serverUrl, string $path, array $pathParams): string\n{\n foreach ($pathParams as $name => $value) {\n $path = str_replace('{' . $name . '}', rawurlencode((string) $value), $path);\n }\n return rtrim($serverUrl, '/') . $path;\n}\n\n/** The default retry predicate: 5xx, 429, and transport timeouts/connect failures. */\nfunction defaultRetryOn(array $context): bool\n{\n if (($context['timedOut'] ?? false) === true) {\n return true;\n }\n $status = $context['status'] ?? 0;\n return $status >= 500 || $status === 429;\n}\n\n/** Delay before the next attempt: `Retry-After` wins; otherwise jittered (fixed|exponential) backoff. */\nfunction retryDelay(int $attempt, array $retry, ?string $retryAfter): float\n{\n if ($retryAfter !== null && ctype_digit($retryAfter)) {\n return (float) $retryAfter;\n }\n $base = (float) ($retry['delay'] ?? 1.0);\n $strategy = $retry['strategy'] ?? 'exponential';\n $delay = $strategy === 'fixed' ? $base : $base * (2 ** ($attempt - 1));\n return $delay * (0.5 + mt_rand() / mt_getrandmax() / 2);\n}\n\n/** Append query params in form style: list values repeat the key (`tag=a&tag=b`). */\nfunction appendQuery(string $url, array $query): string\n{\n $pairs = [];\n foreach ($query as $name => $value) {\n foreach (is_array($value) ? $value : [$value] as $single) {\n $encoded = is_bool($single) ? ($single ? 'true' : 'false') : (string) $single;\n $pairs[] = rawurlencode($name) . '=' . rawurlencode($encoded);\n }\n }\n if ($pairs === []) {\n return $url;\n }\n return $url . (str_contains($url, '?') ? '&' : '?') . implode('&', $pairs);\n}\n\n/** One raw curl exchange. Returns `['status', 'reason', 'headers', 'body', 'url', 'timedOut']`. */\nfunction rawSend(Config $config, array $request): array\n{\n $url = appendQuery($request['url'], $request['query'] ?? []);\n $handle = curl_init($url);\n $headerLines = [];\n foreach ($request['headers'] ?? [] as $name => $value) {\n $headerLines[] = $name . ': ' . $value;\n }\n $responseHeaders = [];\n curl_setopt_array($handle, [\n CURLOPT_CUSTOMREQUEST => $request['method'],\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_HTTPHEADER => $headerLines,\n CURLOPT_HEADERFUNCTION => function ($ch, string $line) use (&$responseHeaders): int {\n $parts = explode(':', $line, 2);\n if (count($parts) === 2) {\n $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);\n }\n return strlen($line);\n },\n ]);\n if (($request['body'] ?? null) !== null) {\n curl_setopt($handle, CURLOPT_POSTFIELDS, $request['body']);\n }\n if ($config->timeout !== null) {\n curl_setopt($handle, CURLOPT_TIMEOUT_MS, (int) round($config->timeout * 1000));\n }\n $body = curl_exec($handle);\n $errno = curl_errno($handle);\n $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);\n $effectiveUrl = (string) curl_getinfo($handle, CURLINFO_EFFECTIVE_URL);\n curl_close($handle);\n if ($errno !== 0) {\n $timedOut = $errno === CURLE_OPERATION_TIMEDOUT || $errno === CURLE_COULDNT_CONNECT;\n return [\n 'status' => 0,\n 'reason' => curl_strerror($errno) ?? 'transport error',\n 'headers' => [],\n 'body' => '',\n 'url' => $effectiveUrl,\n 'timedOut' => $timedOut,\n ];\n }\n return [\n 'status' => $status,\n 'reason' => '',\n 'headers' => $responseHeaders,\n 'body' => is_string($body) ? $body : '',\n 'url' => $effectiveUrl,\n 'timedOut' => false,\n ];\n}\n\n/**\n * Send with retries and middleware. `$request` carries `operationId`, `method`, `url`,\n * `headers`, `query`, and optional `body`/`contentType`/`idempotencyKey`.\n * Returns the raw response array; callers map status >= 400 to `ApiError`.\n */\nfunction send(Config $config, array $request): array\n{\n $headers = $request['headers'] ?? [];\n $headers['X-Redocly-Client'] = $config->clientHeader;\n if (($request['contentType'] ?? null) !== null) {\n $headers['Content-Type'] = $request['contentType'];\n }\n if (($request['idempotencyKey'] ?? null) !== null) {\n $headers['Idempotency-Key'] = $request['idempotencyKey'];\n }\n $request['headers'] = $headers;\n\n $handler = fn (array $req): array => rawSend($config, $req);\n foreach (array_reverse($config->middleware) as $middleware) {\n $next = $handler;\n $handler = fn (array $req): array => $middleware($req, $next);\n }\n\n $attempts = max(1, (int) ($config->retry['attempts'] ?? 3));\n $retryOn = $config->retry['retryOn'] ?? __NAMESPACE__ . '\\\\defaultRetryOn';\n $response = null;\n for ($attempt = 1; $attempt <= $attempts; $attempt++) {\n $response = $handler($request);\n $context = [\n 'status' => $response['status'],\n 'timedOut' => $response['timedOut'],\n 'attempt' => $attempt,\n 'operationId' => $request['operationId'] ?? '',\n ];\n if ($attempt === $attempts || !$retryOn($context)) {\n break;\n }\n $seconds = retryDelay($attempt, $config->retry, $response['headers']['retry-after'] ?? null);\n usleep((int) round($seconds * 1_000_000));\n }\n if ($response['timedOut']) {\n throw new TimeoutError($response['url'], $config->timeout, $attempts);\n }\n if ($response['status'] === 0) {\n throw new \\RuntimeException(\"Request to {$response['url']} failed: {$response['reason']}\");\n }\n return $response;\n}\n\n/** Decoded JSON body (assoc arrays), or null for empty bodies. */\nfunction decodeJson(array $response): mixed\n{\n if ($response['body'] === '') {\n return null;\n }\n return json_decode($response['body'], true);\n}\n\n/** `ApiError` from a non-2xx response. */\nfunction apiErrorFrom(array $response): ApiError\n{\n return new ApiError($response['url'], $response['status'], $response['reason'], decodeJson($response));\n}\n\n/** Walk an RFC 6901 JSON pointer over decoded JSON; null on any miss. */\nfunction resolvePointer(mixed $data, string $pointer): mixed\n{\n if ($pointer === '') {\n return $data;\n }\n foreach (explode('/', substr($pointer, 1)) as $token) {\n $key = str_replace(['~1', '~0'], ['/', '~'], $token);\n if (!is_array($data) || !array_key_exists($key, $data)) {\n return null;\n }\n $data = $data[$key];\n }\n return $data;\n}\n\n/** The `rel=\"next\"` target of a `Link` header, or null. */\nfunction linkNext(?string $header): ?string\n{\n if ($header === null) {\n return null;\n }\n foreach (explode(',', $header) as $part) {\n if (preg_match('/<([^>]+)>\\s*;[^,]*rel=\"?next\"?/', trim($part), $match) === 1) {\n return $match[1];\n }\n }\n return null;\n}\n\n/**\n * Auto-pagination: `$call(array $params): [mixed rawPage, array $response]`, `$spec` is the\n * normalized rule (`style`, `param`, `nextCursor`, `hasMore`, `items`), `$base` the caller's\n * query params. Yields raw decoded pages; generated wrappers hydrate them into models.\n */\nfunction iterPages(callable $call, array $spec, array $base): \\Generator\n{\n $params = $base;\n $style = $spec['style'];\n $seenCursors = [];\n $seenLinks = [];\n $offset = null;\n $page = null;\n while (true) {\n [$raw, $response] = $call($params);\n yield $raw;\n if ($style === 'cursor') {\n $next = resolvePointer($raw, $spec['nextCursor'] ?? '');\n if (isset($spec['hasMore']) && resolvePointer($raw, $spec['hasMore']) !== true) {\n return;\n }\n if (!is_string($next) || $next === '' || isset($seenCursors[$next])) {\n return;\n }\n $seenCursors[$next] = true;\n $params[$spec['param']] = $next;\n } elseif ($style === 'link') {\n $target = linkNext($response['headers']['link'] ?? null);\n if ($target === null || isset($seenLinks[$target])) {\n return;\n }\n $seenLinks[$target] = true;\n $parsed = parse_url($target);\n $linkParams = [];\n parse_str($parsed['query'] ?? '', $linkParams);\n $params = array_merge($params, $linkParams);\n } else {\n $items = resolvePointer($raw, $spec['items'] ?? '');\n $count = is_array($items) ? count($items) : 0;\n if ($count === 0) {\n return;\n }\n if ($style === 'offset') {\n $offset = ($offset ?? (int) ($base[$spec['param']] ?? 0)) + $count;\n $params[$spec['param']] = $offset;\n } else {\n $page = ($page ?? (int) ($base[$spec['param']] ?? 1)) + 1;\n $params[$spec['param']] = $page;\n }\n }\n }\n}\n\n/** Parse one SSE frame; returns `[?ServerSentEvent, ?string lastEventId, ?int retryMs]`. */\nfunction parseSseFrame(string $frame, bool $jsonData): array\n{\n $event = 'message';\n $dataLines = [];\n $id = null;\n $retry = null;\n foreach (explode(\"\\n\", str_replace(\"\\r\\n\", \"\\n\", $frame)) as $line) {\n if ($line === '' || str_starts_with($line, ':')) {\n continue;\n }\n $colon = strpos($line, ':');\n $field = $colon === false ? $line : substr($line, 0, $colon);\n $value = $colon === false ? '' : ltrim(substr($line, $colon + 1), ' ');\n if ($field === 'event') {\n $event = $value;\n } elseif ($field === 'data') {\n $dataLines[] = $value;\n } elseif ($field === 'id') {\n $id = $value;\n } elseif ($field === 'retry' && ctype_digit($value)) {\n $retry = (int) $value;\n }\n }\n if ($dataLines === [] && $id === null && $retry === null) {\n return [null, null, $retry];\n }\n $data = implode(\"\\n\", $dataLines);\n $decoded = $jsonData && $data !== '' ? json_decode($data, true) : $data;\n return [new ServerSentEvent($event, $decoded, $id, $retry), $id, $retry];\n}\n\n/**\n * Stream server-sent events. `$open(array $extraHeaders): \\CurlHandle` returns a configured\n * (not yet executed) handle; this pump drives it with curl_multi, yields parsed frames, and\n * reconnects with `Last-Event-ID` on transient failures (4xx is definitive; backoff <= 30s).\n */\nfunction iterSse(callable $open, bool $jsonData): \\Generator\n{\n $lastEventId = null;\n $retryMs = 3000;\n while (true) {\n $extra = ['Accept' => 'text/event-stream'];\n if ($lastEventId !== null) {\n $extra['Last-Event-ID'] = $lastEventId;\n }\n $handle = $open($extra);\n $buffer = '';\n curl_setopt($handle, CURLOPT_WRITEFUNCTION, function ($ch, string $chunk) use (&$buffer): int {\n $buffer .= $chunk;\n return strlen($chunk);\n });\n $multi = curl_multi_init();\n curl_multi_add_handle($multi, $handle);\n do {\n curl_multi_exec($multi, $running);\n if ($running > 0) {\n curl_multi_select($multi, 0.1);\n }\n while (($split = strpos($buffer, \"\\n\\n\")) !== false || ($split = strpos($buffer, \"\\r\\n\\r\\n\")) !== false) {\n $frameLength = $buffer[$split] === \"\\r\" ? 4 : 2;\n $frame = substr($buffer, 0, $split);\n $buffer = substr($buffer, $split + $frameLength);\n [$event, $id, $retry] = parseSseFrame($frame, $jsonData);\n if ($id !== null) {\n $lastEventId = $id;\n }\n if ($retry !== null) {\n $retryMs = min($retry, 30000);\n }\n if ($event !== null) {\n yield $event;\n }\n }\n } while ($running > 0);\n $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);\n $url = (string) curl_getinfo($handle, CURLINFO_EFFECTIVE_URL);\n curl_multi_remove_handle($multi, $handle);\n curl_multi_close($multi);\n if ($status >= 400 && $status < 500) {\n throw new ApiError($url, $status, '', $buffer);\n }\n // A clean 200 end-of-stream is done; anything else reconnects with Last-Event-ID.\n if ($status === 200) {\n return;\n }\n usleep($retryMs * 1000);\n }\n}\n\n/** Encode an assoc body as `multipart/form-data`; nested values are JSON parts. Returns `[contentType, body]`. */\nfunction toMultipart(array $body): array\n{\n $boundary = 'redocly-' . bin2hex(random_bytes(12));\n $parts = '';\n foreach ($body as $name => $value) {\n $parts .= \"--{$boundary}\\r\\n\";\n if (is_array($value)) {\n $parts .= \"Content-Disposition: form-data; name=\\\"{$name}\\\"\\r\\n\";\n $parts .= \"Content-Type: application/json\\r\\n\\r\\n\";\n $parts .= json_encode($value) . \"\\r\\n\";\n } else {\n $parts .= \"Content-Disposition: form-data; name=\\\"{$name}\\\"\\r\\n\\r\\n\";\n $parts .= (is_bool($value) ? ($value ? 'true' : 'false') : (string) $value) . \"\\r\\n\";\n }\n }\n $parts .= \"--{$boundary}--\\r\\n\";\n return ['multipart/form-data; boundary=' . $boundary, $parts];\n}\n"; + "= 8.1, zero Composer dependencies; HTTP over the curl extension.\n// The generated file re-declares the namespace; the embed strips this header.\n\ndeclare(strict_types=1);\n\nnamespace RedoclyClientRuntime;\n\n/** A response with status >= 400, decoded body attached. */\nfinal class ApiError extends \\RuntimeException\n{\n public function __construct(\n public readonly string $url,\n public readonly int $status,\n public readonly string $reason,\n public readonly mixed $body,\n ) {\n parent::__construct(\"HTTP {$status} {$reason} for {$url}\");\n }\n}\n\n/** Every attempt timed out or failed to connect. */\nfinal class TimeoutError extends \\RuntimeException\n{\n public function __construct(\n public readonly string $url,\n public readonly ?float $timeout,\n public readonly int $attempts,\n ) {\n $seconds = $timeout === null ? 'the configured timeout' : \"{$timeout}s\";\n parent::__construct(\"Request to {$url} timed out after {$seconds} ({$attempts} attempt(s))\");\n }\n}\n\n/** One parsed `text/event-stream` frame. */\nfinal class ServerSentEvent\n{\n public function __construct(\n public readonly string $event,\n public readonly mixed $data,\n public readonly ?string $id = null,\n public readonly ?int $retry = null,\n ) {\n }\n}\n\n/**\n * Per-instance configuration.\n * `auth`: `['bearer' => string|callable, 'basic' => ['username' => ..., 'password' => ...], 'apiKey' => [scheme => string|callable]]`.\n * `retry`: `['attempts' => int, 'delay' => float, 'strategy' => 'exponential'|'fixed', 'retryOn' => callable]`.\n * `middleware`: callables `fn(array $request, callable $next): array` around each attempt.\n */\nfinal class Config\n{\n public function __construct(\n public string $serverUrl = '',\n public array $auth = [],\n public ?float $timeout = null,\n public array $retry = [],\n public array $middleware = [],\n public string $clientHeader = 'redocly-client-generator',\n ) {\n }\n}\n\n/** Resolve a literal-or-callable credential to its string value. */\nfunction resolveToken(mixed $provider): string\n{\n return is_callable($provider) ? (string) $provider() : (string) $provider;\n}\n\n/**\n * Apply the first fully-configured security alternative. `$security` is an OR-list\n * of AND-sets of specs: `['kind' => 'bearer'|'basic'|'apiKey', 'scheme' => ..., 'name' => ?, 'in' => ?]`.\n * Returns `[headers, query, cookies]`.\n */\nfunction resolveAuth(array $security, array $auth): array\n{\n foreach ($security as $andSet) {\n $headers = [];\n $query = [];\n $cookies = [];\n $satisfied = true;\n foreach ($andSet as $spec) {\n if ($spec['kind'] === 'bearer' && isset($auth['bearer'])) {\n $headers['Authorization'] = 'Bearer ' . resolveToken($auth['bearer']);\n } elseif ($spec['kind'] === 'basic' && isset($auth['basic'])) {\n $headers['Authorization'] =\n 'Basic ' . base64_encode($auth['basic']['username'] . ':' . $auth['basic']['password']);\n } elseif ($spec['kind'] === 'apiKey' && isset($auth['apiKey'][$spec['scheme']])) {\n $value = resolveToken($auth['apiKey'][$spec['scheme']]);\n if ($spec['in'] === 'query') {\n $query[$spec['name']] = $value;\n } elseif ($spec['in'] === 'cookie') {\n $cookies[] = $spec['name'] . '=' . rawurlencode($value);\n } else {\n $headers[$spec['name']] = $value;\n }\n } else {\n $satisfied = false;\n break;\n }\n }\n if ($satisfied) {\n return [$headers, $query, $cookies];\n }\n }\n return [[], [], []];\n}\n\n/** Substitute `{param}` templates with encoded values and prefix the server URL. */\nfunction buildUrl(string $serverUrl, string $path, array $pathParams): string\n{\n foreach ($pathParams as $name => $value) {\n $path = str_replace('{' . $name . '}', rawurlencode((string) $value), $path);\n }\n return rtrim($serverUrl, '/') . $path;\n}\n\n/** The default retry predicate: 5xx, 429, and transport timeouts/connect failures. */\nfunction defaultRetryOn(array $context): bool\n{\n if (($context['timedOut'] ?? false) === true) {\n return true;\n }\n $status = $context['status'] ?? 0;\n return $status >= 500 || $status === 429;\n}\n\n/** Delay before the next attempt: `Retry-After` wins; otherwise jittered (fixed|exponential) backoff. */\nfunction retryDelay(int $attempt, array $retry, ?string $retryAfter): float\n{\n if ($retryAfter !== null && ctype_digit($retryAfter)) {\n return (float) $retryAfter;\n }\n $base = (float) ($retry['delay'] ?? 1.0);\n $strategy = $retry['strategy'] ?? 'exponential';\n $delay = $strategy === 'fixed' ? $base : $base * (2 ** ($attempt - 1));\n return $delay * (0.5 + mt_rand() / mt_getrandmax() / 2);\n}\n\n/** Append query params in form style: list values repeat the key (`tag=a&tag=b`). */\nfunction appendQuery(string $url, array $query): string\n{\n $pairs = [];\n foreach ($query as $name => $value) {\n foreach (is_array($value) ? $value : [$value] as $single) {\n $encoded = is_bool($single) ? ($single ? 'true' : 'false') : (string) $single;\n $pairs[] = rawurlencode($name) . '=' . rawurlencode($encoded);\n }\n }\n if ($pairs === []) {\n return $url;\n }\n return $url . (str_contains($url, '?') ? '&' : '?') . implode('&', $pairs);\n}\n\n/** One raw curl exchange. Returns `['status', 'reason', 'headers', 'body', 'url', 'timedOut']`. */\nfunction rawSend(Config $config, array $request): array\n{\n $url = appendQuery($request['url'], $request['query'] ?? []);\n $handle = curl_init($url);\n $headerLines = [];\n foreach ($request['headers'] ?? [] as $name => $value) {\n $headerLines[] = $name . ': ' . $value;\n }\n $responseHeaders = [];\n curl_setopt_array($handle, [\n CURLOPT_CUSTOMREQUEST => $request['method'],\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_HTTPHEADER => $headerLines,\n CURLOPT_HEADERFUNCTION => function ($ch, string $line) use (&$responseHeaders): int {\n $parts = explode(':', $line, 2);\n if (count($parts) === 2) {\n $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);\n }\n return strlen($line);\n },\n ]);\n if (($request['body'] ?? null) !== null) {\n curl_setopt($handle, CURLOPT_POSTFIELDS, $request['body']);\n }\n if ($config->timeout !== null) {\n curl_setopt($handle, CURLOPT_TIMEOUT_MS, (int) round($config->timeout * 1000));\n }\n $body = curl_exec($handle);\n $errno = curl_errno($handle);\n $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);\n $effectiveUrl = (string) curl_getinfo($handle, CURLINFO_EFFECTIVE_URL);\n if ($errno !== 0) {\n $timedOut = $errno === CURLE_OPERATION_TIMEDOUT || $errno === CURLE_COULDNT_CONNECT;\n return [\n 'status' => 0,\n 'reason' => curl_strerror($errno) ?? 'transport error',\n 'headers' => [],\n 'body' => '',\n 'url' => $effectiveUrl,\n 'timedOut' => $timedOut,\n ];\n }\n return [\n 'status' => $status,\n 'reason' => '',\n 'headers' => $responseHeaders,\n 'body' => is_string($body) ? $body : '',\n 'url' => $effectiveUrl,\n 'timedOut' => false,\n ];\n}\n\n/**\n * Send with retries and middleware. `$request` carries `operationId`, `method`, `url`,\n * `headers`, `query`, and optional `body`/`contentType`/`idempotencyKey`.\n * Returns the raw response array; callers map status >= 400 to `ApiError`.\n */\nfunction send(Config $config, array $request): array\n{\n $headers = $request['headers'] ?? [];\n $headers['X-Redocly-Client'] = $config->clientHeader;\n if (($request['contentType'] ?? null) !== null) {\n $headers['Content-Type'] = $request['contentType'];\n }\n if (($request['idempotencyKey'] ?? null) !== null) {\n $headers['Idempotency-Key'] = $request['idempotencyKey'];\n }\n $request['headers'] = $headers;\n\n $handler = fn (array $req): array => rawSend($config, $req);\n foreach (array_reverse($config->middleware) as $middleware) {\n $next = $handler;\n $handler = fn (array $req): array => $middleware($req, $next);\n }\n\n $attempts = max(1, (int) ($config->retry['attempts'] ?? 3));\n $retryOn = $config->retry['retryOn'] ?? __NAMESPACE__ . '\\\\defaultRetryOn';\n $response = null;\n for ($attempt = 1; $attempt <= $attempts; $attempt++) {\n $response = $handler($request);\n $context = [\n 'status' => $response['status'],\n 'timedOut' => $response['timedOut'],\n 'attempt' => $attempt,\n 'operationId' => $request['operationId'] ?? '',\n ];\n if ($attempt === $attempts || !$retryOn($context)) {\n break;\n }\n $seconds = retryDelay($attempt, $config->retry, $response['headers']['retry-after'] ?? null);\n usleep((int) round($seconds * 1_000_000));\n }\n if ($response['timedOut']) {\n throw new TimeoutError($response['url'], $config->timeout, $attempts);\n }\n if ($response['status'] === 0) {\n throw new \\RuntimeException(\"Request to {$response['url']} failed: {$response['reason']}\");\n }\n return $response;\n}\n\n/** Decoded JSON body (assoc arrays), or null for empty bodies. */\nfunction decodeJson(array $response): mixed\n{\n if ($response['body'] === '') {\n return null;\n }\n return json_decode($response['body'], true);\n}\n\n/** `ApiError` from a non-2xx response. */\nfunction apiErrorFrom(array $response): ApiError\n{\n return new ApiError($response['url'], $response['status'], $response['reason'], decodeJson($response));\n}\n\n/** Walk an RFC 6901 JSON pointer over decoded JSON; null on any miss. */\nfunction resolvePointer(mixed $data, string $pointer): mixed\n{\n if ($pointer === '') {\n return $data;\n }\n foreach (explode('/', substr($pointer, 1)) as $token) {\n $key = str_replace(['~1', '~0'], ['/', '~'], $token);\n if (!is_array($data) || !array_key_exists($key, $data)) {\n return null;\n }\n $data = $data[$key];\n }\n return $data;\n}\n\n/** The `rel=\"next\"` target of a `Link` header, or null. */\nfunction linkNext(?string $header): ?string\n{\n if ($header === null) {\n return null;\n }\n foreach (explode(',', $header) as $part) {\n if (preg_match('/<([^>]+)>\\s*;[^,]*rel=\"?next\"?/', trim($part), $match) === 1) {\n return $match[1];\n }\n }\n return null;\n}\n\n/**\n * Auto-pagination: `$call(array $params): [mixed rawPage, array $response]`, `$spec` is the\n * normalized rule (`style`, `param`, `nextCursor`, `hasMore`, `items`), `$base` the caller's\n * query params. Yields raw decoded pages; generated wrappers hydrate them into models.\n */\nfunction iterPages(callable $call, array $spec, array $base): \\Generator\n{\n $params = $base;\n $style = $spec['style'];\n $seenCursors = [];\n $seenLinks = [];\n $offset = null;\n $page = null;\n while (true) {\n [$raw, $response] = $call($params);\n yield $raw;\n if ($style === 'cursor') {\n $next = resolvePointer($raw, $spec['nextCursor'] ?? '');\n if (isset($spec['hasMore']) && resolvePointer($raw, $spec['hasMore']) !== true) {\n return;\n }\n if (!is_string($next) || $next === '' || isset($seenCursors[$next])) {\n return;\n }\n $seenCursors[$next] = true;\n $params[$spec['param']] = $next;\n } elseif ($style === 'link') {\n $target = linkNext($response['headers']['link'] ?? null);\n if ($target === null || isset($seenLinks[$target])) {\n return;\n }\n $seenLinks[$target] = true;\n $parsed = parse_url($target);\n $linkParams = [];\n parse_str($parsed['query'] ?? '', $linkParams);\n $params = array_merge($params, $linkParams);\n } else {\n $items = resolvePointer($raw, $spec['items'] ?? '');\n $count = is_array($items) ? count($items) : 0;\n if ($count === 0) {\n return;\n }\n if ($style === 'offset') {\n $offset = ($offset ?? (int) ($base[$spec['param']] ?? 0)) + $count;\n $params[$spec['param']] = $offset;\n } else {\n $page = ($page ?? (int) ($base[$spec['param']] ?? 1)) + 1;\n $params[$spec['param']] = $page;\n }\n }\n }\n}\n\n/** Parse one SSE frame; returns `[?ServerSentEvent, ?string lastEventId, ?int retryMs]`. */\nfunction parseSseFrame(string $frame, bool $jsonData): array\n{\n $event = 'message';\n $dataLines = [];\n $id = null;\n $retry = null;\n foreach (explode(\"\\n\", str_replace(\"\\r\\n\", \"\\n\", $frame)) as $line) {\n if ($line === '' || str_starts_with($line, ':')) {\n continue;\n }\n $colon = strpos($line, ':');\n $field = $colon === false ? $line : substr($line, 0, $colon);\n $value = $colon === false ? '' : ltrim(substr($line, $colon + 1), ' ');\n if ($field === 'event') {\n $event = $value;\n } elseif ($field === 'data') {\n $dataLines[] = $value;\n } elseif ($field === 'id') {\n $id = $value;\n } elseif ($field === 'retry' && ctype_digit($value)) {\n $retry = (int) $value;\n }\n }\n if ($dataLines === [] && $id === null && $retry === null) {\n return [null, null, $retry];\n }\n $data = implode(\"\\n\", $dataLines);\n $decoded = $jsonData && $data !== '' ? json_decode($data, true) : $data;\n return [new ServerSentEvent($event, $decoded, $id, $retry), $id, $retry];\n}\n\n/**\n * Stream server-sent events. `$open(array $extraHeaders): \\CurlHandle` returns a configured\n * (not yet executed) handle; this pump drives it with curl_multi, yields parsed frames, and\n * reconnects with `Last-Event-ID` on transient failures (4xx is definitive; backoff <= 30s).\n */\nfunction iterSse(callable $open, bool $jsonData): \\Generator\n{\n $lastEventId = null;\n $retryMs = 3000;\n while (true) {\n $extra = ['Accept' => 'text/event-stream'];\n if ($lastEventId !== null) {\n $extra['Last-Event-ID'] = $lastEventId;\n }\n $handle = $open($extra);\n $buffer = '';\n curl_setopt($handle, CURLOPT_WRITEFUNCTION, function ($ch, string $chunk) use (&$buffer): int {\n $buffer .= $chunk;\n return strlen($chunk);\n });\n $multi = curl_multi_init();\n curl_multi_add_handle($multi, $handle);\n do {\n curl_multi_exec($multi, $running);\n if ($running > 0) {\n curl_multi_select($multi, 0.1);\n }\n while (($split = strpos($buffer, \"\\n\\n\")) !== false || ($split = strpos($buffer, \"\\r\\n\\r\\n\")) !== false) {\n $frameLength = $buffer[$split] === \"\\r\" ? 4 : 2;\n $frame = substr($buffer, 0, $split);\n $buffer = substr($buffer, $split + $frameLength);\n [$event, $id, $retry] = parseSseFrame($frame, $jsonData);\n if ($id !== null) {\n $lastEventId = $id;\n }\n if ($retry !== null) {\n $retryMs = min($retry, 30000);\n }\n if ($event !== null) {\n yield $event;\n }\n }\n } while ($running > 0);\n $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);\n $url = (string) curl_getinfo($handle, CURLINFO_EFFECTIVE_URL);\n curl_multi_remove_handle($multi, $handle);\n curl_multi_close($multi);\n if ($status >= 400 && $status < 500) {\n throw new ApiError($url, $status, '', $buffer);\n }\n // A clean 200 end-of-stream is done; anything else reconnects with Last-Event-ID.\n if ($status === 200) {\n return;\n }\n usleep($retryMs * 1000);\n }\n}\n\n/** Encode an assoc body as `multipart/form-data`; nested values are JSON parts. Returns `[contentType, body]`. */\nfunction toMultipart(array $body): array\n{\n $boundary = 'redocly-' . bin2hex(random_bytes(12));\n $parts = '';\n foreach ($body as $name => $value) {\n $parts .= \"--{$boundary}\\r\\n\";\n if (is_array($value)) {\n $parts .= \"Content-Disposition: form-data; name=\\\"{$name}\\\"\\r\\n\";\n $parts .= \"Content-Type: application/json\\r\\n\\r\\n\";\n $parts .= json_encode($value) . \"\\r\\n\";\n } else {\n $parts .= \"Content-Disposition: form-data; name=\\\"{$name}\\\"\\r\\n\\r\\n\";\n $parts .= (is_bool($value) ? ($value ? 'true' : 'false') : (string) $value) . \"\\r\\n\";\n }\n }\n $parts .= \"--{$boundary}--\\r\\n\";\n return ['multipart/form-data; boundary=' . $boundary, $parts];\n}\n"; diff --git a/packages/client-generator/src/generators/php.ts b/packages/client-generator/src/generators/php.ts index e55141f235..2638536776 100644 --- a/packages/client-generator/src/generators/php.ts +++ b/packages/client-generator/src/generators/php.ts @@ -118,6 +118,7 @@ export function phpType(schema: SchemaModel, model: ApiModel): string { /** Wire value → typed value expression, or undefined when the raw value is already right. */ function hydration(schema: SchemaModel, expr: string, model: ApiModel): string | undefined { 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})`; @@ -141,6 +142,7 @@ function hydration(schema: SchemaModel, expr: string, model: ApiModel): string | /** Typed value → wire value expression, or undefined when it serializes as-is. */ function serialization(schema: SchemaModel, expr: string, model: ApiModel): string | undefined { 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()`; diff --git a/tests/e2e/generate-client/php-consumer/.gitignore b/tests/e2e/generate-client/php-consumer/.gitignore new file mode 100644 index 0000000000..684bec4c9f --- /dev/null +++ b/tests/e2e/generate-client/php-consumer/.gitignore @@ -0,0 +1 @@ +client/ diff --git a/tests/e2e/generate-client/php-consumer/smoke.php b/tests/e2e/generate-client/php-consumer/smoke.php new file mode 100644 index 0000000000..3abfdd2433 --- /dev/null +++ b/tests/e2e/generate-client/php-consumer/smoke.php @@ -0,0 +1,40 @@ +getPetById(1); +if (!($pet instanceof Pet) || $pet->name === '') { + fwrite(STDERR, "pet should hydrate into the Pet class\n"); + exit(1); +} + +// Collection + request body round-trips. +$client->listPets(); +$client->createPet(new Pet(name: 'Smokey')); + +// A non-2xx throws the structured ApiError (a wrong base path 404s every route). +$broken = new Client(new Config(serverUrl: $base . '/nowhere')); +try { + $broken->getPetById(1); + fwrite(STDERR, "expected an ApiError\n"); + exit(1); +} catch (ApiError $error) { + if ($error->status !== 404) { + fwrite(STDERR, "expected 404, got {$error->status}\n"); + exit(1); + } +} + +echo "PHP_SMOKE_OK\n"; diff --git a/tests/e2e/generate-client/php.test.ts b/tests/e2e/generate-client/php.test.ts new file mode 100644 index 0000000000..d971022b00 --- /dev/null +++ b/tests/e2e/generate-client/php.test.ts @@ -0,0 +1,60 @@ +import { spawnSync, type ChildProcess } from 'node:child_process'; +import { existsSync, rmSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { generate, killServer, startServer } from './helpers.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixture = join(__dirname, 'fixtures/base.yaml'); +const consumerDir = join(__dirname, 'php-consumer'); +const generatedFile = join(consumerDir, 'client/client.php'); + +const SERVER_PORT = 3109; +const SERVER_BASE = `http://127.0.0.1:${SERVER_PORT}`; + +const hasPhp = spawnSync('php', ['--version']).status === 0; + +describe('generate-client php generator (end-to-end)', () => { + afterAll(() => { + rmSync(join(consumerDir, 'client'), { recursive: true, force: true }); + }); + + it('generates a self-contained client.php from the CLI', () => { + generate(fixture, join(consumerDir, 'client/client.ts'), ['--generator', 'php']); + expect(existsSync(generatedFile)).toBe(true); + }); + + it.skipIf(!hasPhp)('the generated client parses and declares (php -l + require)', () => { + const lint = spawnSync('php', ['-l', generatedFile], { encoding: 'utf-8' }); + expect(lint.status, `${lint.stdout}\n${lint.stderr}`).toBe(0); + const declare = spawnSync('php', ['-r', `require '${generatedFile}'; echo 'DECLARED';`], { + encoding: 'utf-8', + }); + expect(declare.status, `${declare.stdout}\n${declare.stderr}`).toBe(0); + }); + + it.skipIf(!hasPhp)( + 'the smoke runs real HTTP: hydration, bodies, ApiError', + async () => { + let serverProcess: ChildProcess | undefined; + try { + serverProcess = await startServer( + join(__dirname, 'base-consumer/server.ts'), + join(__dirname, 'base-consumer'), + { BASE_SERVER_PORT: String(SERVER_PORT) }, + SERVER_BASE, + 'php-smoke-server' + ); + const result = spawnSync('php', [join(consumerDir, 'smoke.php'), SERVER_BASE], { + encoding: 'utf-8', + }); + expect(result.status, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`).toBe(0); + expect(result.stdout).toContain('PHP_SMOKE_OK'); + } finally { + if (serverProcess) await killServer(serverProcess); + } + }, + 60_000 + ); +}); diff --git a/tests/harness/generate-client/github.harness.ts b/tests/harness/generate-client/github.harness.ts index 5f76dfd701..cc789a549a 100644 --- a/tests/harness/generate-client/github.harness.ts +++ b/tests/harness/generate-client/github.harness.ts @@ -6,7 +6,9 @@ import { fetchGithubDescription, goBar, hasGo, + hasPhp, hasPython, + phpBar, pythonBar, typescriptBar, } from './helpers.js'; @@ -33,4 +35,8 @@ describe('github REST description', () => { it.skipIf(!hasGo)('go builds and vets cleanly', () => { goBar(github); }); + + it.skipIf(!hasPhp)('php parses and declares cleanly', () => { + phpBar(github); + }); }); diff --git a/tests/harness/generate-client/helpers.ts b/tests/harness/generate-client/helpers.ts index 000fd93ce6..50286474fa 100644 --- a/tests/harness/generate-client/helpers.ts +++ b/tests/harness/generate-client/helpers.ts @@ -29,6 +29,7 @@ export async function fetchGithubDescription(): Promise { return cached; } +export const hasPhp = spawnSync('php', ['--version']).status === 0; export const hasPython = spawnSync('python3', ['--version']).status === 0; export const hasHttpx = hasPython && spawnSync('python3', ['-c', 'import httpx']).status === 0; export const hasGo = spawnSync('go', ['version']).status === 0; @@ -93,6 +94,18 @@ export function pythonBar(description: string): void { expect(check.status, check.stderr).toBe(0); } +/** PHP bar: the generated `.php` parses (`php -l`) and declares (`require`). */ +export function phpBar(description: string): void { + const dir = generateWith('php', description); + const lint = spawnSync('php', ['-l', 'client.php'], { cwd: dir, encoding: 'utf-8' }); + expect(lint.status, `${lint.stdout}\n${lint.stderr}`).toBe(0); + const declare = spawnSync('php', ['-r', "require 'client.php'; echo 'DECLARED';"], { + cwd: dir, + encoding: 'utf-8', + }); + expect(declare.status, `${declare.stdout}\n${declare.stderr}`).toBe(0); +} + /** Go bar: `go build` + `go vet` (vet catches json tags on unexported fields). */ export function goBar(description: string): void { const dir = generateWith('go', description); diff --git a/tests/harness/generate-client/rebilly.harness.ts b/tests/harness/generate-client/rebilly.harness.ts index f6cd2eb5a6..39ff6625fe 100644 --- a/tests/harness/generate-client/rebilly.harness.ts +++ b/tests/harness/generate-client/rebilly.harness.ts @@ -4,7 +4,16 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { cliBar, goBar, hasGo, hasPython, pythonBar, typescriptBar } from './helpers.js'; +import { + cliBar, + goBar, + hasGo, + hasPhp, + hasPython, + phpBar, + pythonBar, + typescriptBar, +} from './helpers.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const rebilly = join(__dirname, '../../smoke/rebilly/rebilly-description.yaml'); @@ -25,4 +34,8 @@ describe('rebilly description', () => { it.skipIf(!hasGo)('go builds and vets cleanly', () => { goBar(rebilly); }); + + it.skipIf(!hasPhp)('php parses and declares cleanly', () => { + phpBar(rebilly); + }); }); From 1b8895b61be23075d10e48ad2ad8731113134afc Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 09:39:58 +0300 Subject: [PATCH 036/211] docs(client-generator): PHP SDK docs and changeset --- .changeset/php-generator.md | 6 ++++ docs/@v2/commands/generate-client.md | 2 +- docs/@v2/configuration/reference/client.md | 32 +++++++++++----------- docs/@v2/guides/use-generated-client.md | 18 ++++++++++++ 4 files changed, 41 insertions(+), 17 deletions(-) create mode 100644 .changeset/php-generator.md diff --git a/.changeset/php-generator.md b/.changeset/php-generator.md new file mode 100644 index 0000000000..ce76cf9eed --- /dev/null +++ b/.changeset/php-generator.md @@ -0,0 +1,6 @@ +--- +'@redocly/client-generator': minor +'@redocly/cli': minor +--- + +Added a built-in `php` generator — a self-contained, zero-dependency PHP 8.1+ SDK over the curl extension with promoted-constructor model classes, native backed enums, discriminated-union dispatchers, a client with typed named-argument methods, auth, retries, timeouts, idempotency keys, middleware, pagination generators (`Pages()` / `Items()`), SSE streaming, and multipart bodies, plus PHP `x-codeSamples`. diff --git a/docs/@v2/commands/generate-client.md b/docs/@v2/commands/generate-client.md index c498872bb3..dffde50bba 100644 --- a/docs/@v2/commands/generate-client.md +++ b/docs/@v2/commands/generate-client.md @@ -40,7 +40,7 @@ redocly generate-client [--help] [--version] | `--output-mode` | string | File layout. See [Choose an output mode](#choose-an-output-mode).
**Possible values:** `single`, `split`. Default value is `single`. | | `--runtime` | string | Where the client's engine lives. See [Choose a runtime](#choose-a-runtime).
**Possible values:** `inline`, `package`. Default value is `inline`. | | `--import-ext` | string | Extension in generated relative imports. See [Run with Node directly](../guides/use-generated-client.md#run-with-node-directly).
**Possible values:** `js` (the tsc/bundler convention), `ts` (for Node's built-in type stripping). Default value is `js`. | -| `--generator` | [string] | Generator to run — a built-in name (`tanstack-query` also has `-vue`/`-svelte`/`-solid` variants; `python`/`go` emit full Python and Go SDKs; `cli` emits a command-line interface) or a custom generator's path or package; repeat the flag to run several. Default value is `sdk`. See [Generators](../guides/use-generated-client.md#generators). | +| `--generator` | [string] | Generator to run — a built-in name (`tanstack-query` also has `-vue`/`-svelte`/`-solid` variants; `python`/`go`/`php` emit full Python, Go, and PHP SDKs; `cli` emits a command-line interface) or a custom generator's path or package; repeat the flag to run several. Default value is `sdk`. See [Generators](../guides/use-generated-client.md#generators). | | `--args-style` | string | How operation inputs are passed. See [Argument style](../guides/use-generated-client.md#argument-style).
**Possible values:** `flat`, `grouped`. Default value is `flat`. | | `--error-mode` | string | How operations report HTTP errors. See [Error handling](../guides/use-generated-client.md#error-handling).
**Possible values:** `throw`, `result`. Default value is `throw`. | | `--date-type` | string | Type of `date`/`date-time` fields; pair `Date` with the `transformers` generator.
**Possible values:** `string`, `Date`. Default value is `string`. | diff --git a/docs/@v2/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md index d351536735..d8b51005ae 100644 --- a/docs/@v2/configuration/reference/client.md +++ b/docs/@v2/configuration/reference/client.md @@ -17,22 +17,22 @@ Each scalar option mirrors the matching CLI flag and shares its default — see The `pagination` option is config-only — a structured, durable contract that belongs in versioned configuration rather than a shell string. For runs without a configuration file, declare pagination per operation with the `x-redocly-pagination` extension in the description, or pass `pagination` to the programmatic `generateClient(...)`. -| Option | Type | Description | -| ---------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `generators` | [string] | Generators to run, in order. Each entry is a built-in name (`sdk`, `zod`, `tanstack-query` — or its `-vue`/`-svelte`/`-solid` variants — `swr`, `mock`, `transformers`, `cli`, `python`, `go`) or a custom generator's path or package name. | -| `outputMode` | string | File layout: `single` or `split`. | -| `runtime` | string | Runtime distribution: `inline` or `package`. | -| `importExt` | string | Extension in generated relative imports: `js` (default, for tsc and bundlers) or `ts` (for Node's built-in type stripping). | -| `argsStyle` | string | How operation inputs are passed: `flat` or `grouped`. | -| `errorMode` | string | How operations report HTTP errors: `throw` or `result`. | -| `dateType` | string | Type of `date`/`date-time` fields: `string` or `Date`. | -| `mockData` | string | Data mode for the `mock` generator: `static` or `faker`. | -| `mockSeed` | number | Seed for `faker`-mode mocks. | -| `queryKeyPrefix` | string | Leading element for every `tanstack-query` query/mutation key — namespaces the cache when several generated APIs share one QueryClient. Config-only, no flag. | -| `codeSamples` | boolean | Emit `.code-samples.yaml` — an OpenAPI Overlay adding per-operation `x-codeSamples` collected from every selected generator that implements `sample()`. Config-only, no flag. | -| `serverUrl` | string | Server URL included in the client as its default; falls back to `servers[0].url`. | -| `setup` | string | Path to a publisher setup module that gets included in the client — pre-configures defaults such as the server URL, retries, headers, and middleware. See [Publisher defaults](../../guides/customize-client-generation.md#publisher-defaults). | -| `pagination` | [Pagination object](#pagination-object) | Declares how the API paginates, so paginated operations gain typed `.pages()`/`.items()` async iterators. | +| Option | Type | Description | +| ---------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `generators` | [string] | Generators to run, in order. Each entry is a built-in name (`sdk`, `zod`, `tanstack-query` — or its `-vue`/`-svelte`/`-solid` variants — `swr`, `mock`, `transformers`, `cli`, `python`, `go`, `php`) or a custom generator's path or package name. | +| `outputMode` | string | File layout: `single` or `split`. | +| `runtime` | string | Runtime distribution: `inline` or `package`. | +| `importExt` | string | Extension in generated relative imports: `js` (default, for tsc and bundlers) or `ts` (for Node's built-in type stripping). | +| `argsStyle` | string | How operation inputs are passed: `flat` or `grouped`. | +| `errorMode` | string | How operations report HTTP errors: `throw` or `result`. | +| `dateType` | string | Type of `date`/`date-time` fields: `string` or `Date`. | +| `mockData` | string | Data mode for the `mock` generator: `static` or `faker`. | +| `mockSeed` | number | Seed for `faker`-mode mocks. | +| `queryKeyPrefix` | string | Leading element for every `tanstack-query` query/mutation key — namespaces the cache when several generated APIs share one QueryClient. Config-only, no flag. | +| `codeSamples` | boolean | Emit `.code-samples.yaml` — an OpenAPI Overlay adding per-operation `x-codeSamples` collected from every selected generator that implements `sample()`. Config-only, no flag. | +| `serverUrl` | string | Server URL included in the client as its default; falls back to `servers[0].url`. | +| `setup` | string | Path to a publisher setup module that gets included in the client — pre-configures defaults such as the server URL, retries, headers, and middleware. See [Publisher defaults](../../guides/customize-client-generation.md#publisher-defaults). | +| `pagination` | [Pagination object](#pagination-object) | Declares how the API paginates, so paginated operations gain typed `.pages()`/`.items()` async iterators. | ### Pagination object diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index cfabaccf13..1b908d672e 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -71,6 +71,24 @@ for order in client.list_orders_items(limit=50): print(order) ``` +### PHP SDK + +The `php` generator emits a self-contained `.php` — a full PHP SDK over the curl extension (zero Composer dependencies, PHP ≥ 8.1): +promoted-constructor classes with `fromArray`/`toArray` hydration (allOf flattened, native backed enums, `match`-based discriminated-union dispatchers), a `Client` with one typed method per operation (optional query params as nullable named arguments), auth, retries with `Retry-After` and jittered backoff, timeouts, idempotency keys, middleware callables, pagination generators (`Pages()` / `Items()`), SSE streaming, and multipart bodies. +Exceptions are the error mode (`ApiError` / `TimeoutError`); `errorMode` does not change the output. +The namespace derives from the API title (for example `CafeOrdersApi`). + +```php +require 'client.php'; + +use CafeOrdersApi\{Client, Config}; + +$client = new Client(new Config(auth: ['bearer' => 'TOKEN'])); +foreach ($client->listOrdersItems(limit: 50) as $order) { + echo $order->id, PHP_EOL; +} +``` + ### Go SDK The `go` generator emits a self-contained `.go` — a full Go SDK over the standard library (zero dependencies, Go ≥ 1.21): From 1eb95ec92d5643969d7f0386f8461edb8e1e9928 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 10:05:15 +0300 Subject: [PATCH 037/211] docs(client-generator): cli, python, go, and php examples --- .../scripts/typecheck-examples.mjs | 7 ++++-- tests/e2e/generate-client/examples/README.md | 6 ++++- .../generate-client/examples/cli/.gitignore | 3 +++ .../generate-client/examples/cli/README.md | 21 +++++++++++++++++ .../generate-client/examples/cli/order.json | 4 ++++ .../generate-client/examples/cli/package.json | 15 ++++++++++++ .../generate-client/examples/cli/redocly.yaml | 10 ++++++++ .../examples/cli/tsconfig.json | 7 ++++++ .../examples/go-sdk/.gitignore | 3 +++ .../generate-client/examples/go-sdk/README.md | 12 ++++++++++ .../generate-client/examples/go-sdk/go.mod | 3 +++ .../generate-client/examples/go-sdk/main.go | 23 +++++++++++++++++++ .../examples/go-sdk/package.json | 12 ++++++++++ .../examples/go-sdk/redocly.yaml | 8 +++++++ .../examples/php-sdk/.gitignore | 3 +++ .../examples/php-sdk/README.md | 12 ++++++++++ .../examples/php-sdk/package.json | 12 ++++++++++ .../examples/php-sdk/redocly.yaml | 8 +++++++ .../examples/php-sdk/src/main.php | 15 ++++++++++++ .../examples/python-sdk/.gitignore | 4 ++++ .../examples/python-sdk/README.md | 13 +++++++++++ .../examples/python-sdk/package.json | 12 ++++++++++ .../examples/python-sdk/redocly.yaml | 8 +++++++ .../examples/python-sdk/src/main.py | 12 ++++++++++ 24 files changed, 230 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/generate-client/examples/cli/.gitignore create mode 100644 tests/e2e/generate-client/examples/cli/README.md create mode 100644 tests/e2e/generate-client/examples/cli/order.json create mode 100644 tests/e2e/generate-client/examples/cli/package.json create mode 100644 tests/e2e/generate-client/examples/cli/redocly.yaml create mode 100644 tests/e2e/generate-client/examples/cli/tsconfig.json create mode 100644 tests/e2e/generate-client/examples/go-sdk/.gitignore create mode 100644 tests/e2e/generate-client/examples/go-sdk/README.md create mode 100644 tests/e2e/generate-client/examples/go-sdk/go.mod create mode 100644 tests/e2e/generate-client/examples/go-sdk/main.go create mode 100644 tests/e2e/generate-client/examples/go-sdk/package.json create mode 100644 tests/e2e/generate-client/examples/go-sdk/redocly.yaml create mode 100644 tests/e2e/generate-client/examples/php-sdk/.gitignore create mode 100644 tests/e2e/generate-client/examples/php-sdk/README.md create mode 100644 tests/e2e/generate-client/examples/php-sdk/package.json create mode 100644 tests/e2e/generate-client/examples/php-sdk/redocly.yaml create mode 100644 tests/e2e/generate-client/examples/php-sdk/src/main.php create mode 100644 tests/e2e/generate-client/examples/python-sdk/.gitignore create mode 100644 tests/e2e/generate-client/examples/python-sdk/README.md create mode 100644 tests/e2e/generate-client/examples/python-sdk/package.json create mode 100644 tests/e2e/generate-client/examples/python-sdk/redocly.yaml create mode 100644 tests/e2e/generate-client/examples/python-sdk/src/main.py diff --git a/packages/client-generator/scripts/typecheck-examples.mjs b/packages/client-generator/scripts/typecheck-examples.mjs index f2dc49f12f..8509c0fa39 100644 --- a/packages/client-generator/scripts/typecheck-examples.mjs +++ b/packages/client-generator/scripts/typecheck-examples.mjs @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process'; -import { readdirSync } from 'node:fs'; +import { existsSync, readdirSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -19,7 +19,10 @@ const examples = readdirSync(examplesDir, { withFileTypes: true }) let failed = false; for (const name of examples) { - const res = spawnSync(tsc, ['--noEmit', '-p', join(examplesDir, name, 'tsconfig.json')], { + const tsconfig = join(examplesDir, name, 'tsconfig.json'); + // Language-SDK examples (python/go/php) have no TypeScript consumer to check. + if (!existsSync(tsconfig)) continue; + const res = spawnSync(tsc, ['--noEmit', '-p', tsconfig], { stdio: 'inherit', }); if (res.status !== 0) failed = true; diff --git a/tests/e2e/generate-client/examples/README.md b/tests/e2e/generate-client/examples/README.md index cf4237bf65..45f6cb6f19 100644 --- a/tests/e2e/generate-client/examples/README.md +++ b/tests/e2e/generate-client/examples/README.md @@ -2,7 +2,7 @@ Runnable examples of clients generated by `@redocly/client-generator`. Most are Vite apps that _consume_ a client generated via the `redocly generate-client` CLI (a `redocly.yaml`); `programmatic` _generates_ one with the `generateClient(...)` API. -Nine examples share the cafe spec in [`_shared/cafe.yaml`](./_shared/cafe.yaml); the rest carry their own. +Most share the cafe spec in [`_shared/cafe.yaml`](./_shared/cafe.yaml); the rest carry their own. The generated client under `src/api/` is gitignored — CI regenerates every client and type-checks the consumer code against it (the `examples` job), and `zero-install-quickstart` keeps its client committed as the canonical browsable copy, drift-checked in `tests/e2e/generate-client/examples.test.ts`. @@ -26,6 +26,10 @@ The generated client under `src/api/` is gitignored — CI regenerates every cli | [custom-generator](./custom-generator) | CLI · `sdk` + custom generator | a local `generators` plugin emitting a `: 'METHOD /path'` route map next to the sdk | | [ast-toolkit-generator](./ast-toolkit-generator) | CLI · `sdk` + custom generator | a plugin emitting real TypeScript AST via `@redocly/client-generator/generate` (`schemaToTypeNode`, `printStatements`) — a typed response-shape map | | [nested-facade](./nested-facade) | CLI · `sdk` + custom generator | `api..` facade derived from the spec's tags by a plugin — regenerates with the spec | +| [cli](./cli) | CLI · `sdk`, `zod`, `cli` | a bin-ready command-line interface over the client: typed flags, `--json` bodies, `--dry-run`, a documented exit-code contract | +| [python-sdk](./python-sdk) | CLI · `python` | a full Python SDK (httpx): typed dataclasses, sync/async clients, pagination iterators | +| [go-sdk](./go-sdk) | CLI · `go` | a full Go SDK (stdlib-only): typed structs, `(T, error)` methods, `context.Context` | +| [php-sdk](./php-sdk) | CLI · `php` | a full PHP SDK (curl extension): promoted-constructor classes, native enums, named-argument methods | ## Run one diff --git a/tests/e2e/generate-client/examples/cli/.gitignore b/tests/e2e/generate-client/examples/cli/.gitignore new file mode 100644 index 0000000000..612acc5cae --- /dev/null +++ b/tests/e2e/generate-client/examples/cli/.gitignore @@ -0,0 +1,3 @@ +node_modules +src/api/ +package-lock.json diff --git a/tests/e2e/generate-client/examples/cli/README.md b/tests/e2e/generate-client/examples/cli/README.md new file mode 100644 index 0000000000..3957918001 --- /dev/null +++ b/tests/e2e/generate-client/examples/cli/README.md @@ -0,0 +1,21 @@ +# cli + +The `cli` generator emits `src/api/client.cli.ts` — a bin-ready, zero-dependency command-line interface over the generated client. +Path params are positional, query params become typed `--kebab-name` flags, and JSON bodies arrive via `--json ''`, `--json @file.json`, or `--json @-` (stdin). +With `zod` co-selected (as here), requests are validated before they are sent — an invalid body exits with code 3 and never reaches the network. + +Generate the client, then drive the API from the shell: + +```sh +npm run generate + +npx tsx src/api/client.cli.ts --help +npx tsx src/api/client.cli.ts Products listMenuItems --limit 3 +npx tsx src/api/client.cli.ts Orders createOrder --json @order.json --dry-run +npx tsx src/api/client.cli.ts schema createOrder +``` + +`--dry-run` prints the prepared request (credentials redacted) without sending it. +Credentials come from environment variables derived from the file stem: `CLIENT_TOKEN` for bearer auth here, or pass `--token`. +Exit codes are a documented contract (0 ok, 1 API error, 2 auth, 3 validation, 4 usage), and errors print one JSON object to stderr so stdout stays clean for piping. +To ship a real bin, compile with `tsc` and point `package.json`'s `bin` at the compiled file. diff --git a/tests/e2e/generate-client/examples/cli/order.json b/tests/e2e/generate-client/examples/cli/order.json new file mode 100644 index 0000000000..c96b555278 --- /dev/null +++ b/tests/e2e/generate-client/examples/cli/order.json @@ -0,0 +1,4 @@ +{ + "customerName": "Ada Lovelace", + "orderItems": [{ "menuItemId": "prd_01h1s5z6vf2mm1mz3hevnn9va7", "quantity": 2 }] +} diff --git a/tests/e2e/generate-client/examples/cli/package.json b/tests/e2e/generate-client/examples/cli/package.json new file mode 100644 index 0000000000..bf625bcda8 --- /dev/null +++ b/tests/e2e/generate-client/examples/cli/package.json @@ -0,0 +1,15 @@ +{ + "name": "@redocly-examples/cli", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "generate": "redocly generate-client" + }, + "devDependencies": { + "@redocly/cli": "latest", + "tsx": "^4.19.0", + "typescript": "^5.5.0", + "zod": "^4.0.0" + } +} diff --git a/tests/e2e/generate-client/examples/cli/redocly.yaml b/tests/e2e/generate-client/examples/cli/redocly.yaml new file mode 100644 index 0000000000..2d5e6704b6 --- /dev/null +++ b/tests/e2e/generate-client/examples/cli/redocly.yaml @@ -0,0 +1,10 @@ +# redocly.yaml — drives `redocly generate-client` for this example. +apis: + cli: + root: ../_shared/cafe.yaml + clientOutput: ./src/api/client.ts + client: + generators: + - sdk + - zod + - cli diff --git a/tests/e2e/generate-client/examples/cli/tsconfig.json b/tests/e2e/generate-client/examples/cli/tsconfig.json new file mode 100644 index 0000000000..e1af1a1d9d --- /dev/null +++ b/tests/e2e/generate-client/examples/cli/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "types": ["node"] + }, + "include": ["src"] +} diff --git a/tests/e2e/generate-client/examples/go-sdk/.gitignore b/tests/e2e/generate-client/examples/go-sdk/.gitignore new file mode 100644 index 0000000000..612acc5cae --- /dev/null +++ b/tests/e2e/generate-client/examples/go-sdk/.gitignore @@ -0,0 +1,3 @@ +node_modules +src/api/ +package-lock.json diff --git a/tests/e2e/generate-client/examples/go-sdk/README.md b/tests/e2e/generate-client/examples/go-sdk/README.md new file mode 100644 index 0000000000..1c07fa5ff8 --- /dev/null +++ b/tests/e2e/generate-client/examples/go-sdk/README.md @@ -0,0 +1,12 @@ +# go-sdk + +The `go` generator emits `src/api/client.go` — a full Go SDK over the standard library (zero dependencies, Go ≥ 1.21): +structs with `json` tags, typed-const enums, a context-aware `Client` with `(T, error)` methods, auth, retries, pagination iterators (`Pages` / `Items`), SSE streaming, and multipart bodies. + +```sh +npm run generate +go run . +``` + +The example calls the live demo API at `https://api.cafe.redocly.com` and prints three menu item names. +`MenuItem` is a discriminated union, so items arrive as `any`; `UnmarshalMenuItem` dispatches them into `Beverage`/`Dessert` when you need the typed form. diff --git a/tests/e2e/generate-client/examples/go-sdk/go.mod b/tests/e2e/generate-client/examples/go-sdk/go.mod new file mode 100644 index 0000000000..9e02dcd7b9 --- /dev/null +++ b/tests/e2e/generate-client/examples/go-sdk/go.mod @@ -0,0 +1,3 @@ +module cafe.example + +go 1.21 diff --git a/tests/e2e/generate-client/examples/go-sdk/main.go b/tests/e2e/generate-client/examples/go-sdk/main.go new file mode 100644 index 0000000000..46494ade02 --- /dev/null +++ b/tests/e2e/generate-client/examples/go-sdk/main.go @@ -0,0 +1,23 @@ +// Consume the generated Go SDK: typed structs over the standard library. +package main + +import ( + "context" + "fmt" + + client "cafe.example/src/api" +) + +func main() { + api := client.New(client.Config{}) + limit := int64(3) + menu, err := api.ListMenuItems(context.Background(), &client.ListMenuItemsParams{Limit: &limit}) + if err != nil { + panic(err) + } + for _, item := range menu.Items { + if fields, ok := item.(map[string]any); ok { + fmt.Println(fields["name"]) + } + } +} diff --git a/tests/e2e/generate-client/examples/go-sdk/package.json b/tests/e2e/generate-client/examples/go-sdk/package.json new file mode 100644 index 0000000000..8e3dd612e5 --- /dev/null +++ b/tests/e2e/generate-client/examples/go-sdk/package.json @@ -0,0 +1,12 @@ +{ + "name": "@redocly-examples/go-sdk", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "generate": "redocly generate-client" + }, + "devDependencies": { + "@redocly/cli": "latest" + } +} diff --git a/tests/e2e/generate-client/examples/go-sdk/redocly.yaml b/tests/e2e/generate-client/examples/go-sdk/redocly.yaml new file mode 100644 index 0000000000..a3838b8924 --- /dev/null +++ b/tests/e2e/generate-client/examples/go-sdk/redocly.yaml @@ -0,0 +1,8 @@ +# redocly.yaml — drives `redocly generate-client` for this example. +apis: + go-sdk: + root: ../_shared/cafe.yaml + clientOutput: ./src/api/client.ts + client: + generators: + - go diff --git a/tests/e2e/generate-client/examples/php-sdk/.gitignore b/tests/e2e/generate-client/examples/php-sdk/.gitignore new file mode 100644 index 0000000000..612acc5cae --- /dev/null +++ b/tests/e2e/generate-client/examples/php-sdk/.gitignore @@ -0,0 +1,3 @@ +node_modules +src/api/ +package-lock.json diff --git a/tests/e2e/generate-client/examples/php-sdk/README.md b/tests/e2e/generate-client/examples/php-sdk/README.md new file mode 100644 index 0000000000..097ca5e2ad --- /dev/null +++ b/tests/e2e/generate-client/examples/php-sdk/README.md @@ -0,0 +1,12 @@ +# php-sdk + +The `php` generator emits `src/api/client.php` — a full PHP SDK over the curl extension (zero Composer dependencies, PHP ≥ 8.1): +promoted-constructor classes with `fromArray`/`toArray` hydration, native backed enums, a `Client` with typed named-argument methods, auth, retries, pagination generators (`Pages()` / `Items()`), SSE streaming, and multipart bodies. +The namespace derives from the API title (`RedoclyCafe` here). + +```sh +npm run generate +php src/main.php +``` + +The example calls the live demo API at `https://api.cafe.redocly.com` and prints three menu item names. diff --git a/tests/e2e/generate-client/examples/php-sdk/package.json b/tests/e2e/generate-client/examples/php-sdk/package.json new file mode 100644 index 0000000000..1f8d0b6554 --- /dev/null +++ b/tests/e2e/generate-client/examples/php-sdk/package.json @@ -0,0 +1,12 @@ +{ + "name": "@redocly-examples/php-sdk", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "generate": "redocly generate-client" + }, + "devDependencies": { + "@redocly/cli": "latest" + } +} diff --git a/tests/e2e/generate-client/examples/php-sdk/redocly.yaml b/tests/e2e/generate-client/examples/php-sdk/redocly.yaml new file mode 100644 index 0000000000..3463b52368 --- /dev/null +++ b/tests/e2e/generate-client/examples/php-sdk/redocly.yaml @@ -0,0 +1,8 @@ +# redocly.yaml — drives `redocly generate-client` for this example. +apis: + php-sdk: + root: ../_shared/cafe.yaml + clientOutput: ./src/api/client.ts + client: + generators: + - php diff --git a/tests/e2e/generate-client/examples/php-sdk/src/main.php b/tests/e2e/generate-client/examples/php-sdk/src/main.php new file mode 100644 index 0000000000..f4a992f6fc --- /dev/null +++ b/tests/e2e/generate-client/examples/php-sdk/src/main.php @@ -0,0 +1,15 @@ +listMenuItems(limit: 3); +foreach ($menu->items as $item) { + echo $item['name'], PHP_EOL; +} diff --git a/tests/e2e/generate-client/examples/python-sdk/.gitignore b/tests/e2e/generate-client/examples/python-sdk/.gitignore new file mode 100644 index 0000000000..9f2ae7a01e --- /dev/null +++ b/tests/e2e/generate-client/examples/python-sdk/.gitignore @@ -0,0 +1,4 @@ +node_modules +src/api/ +package-lock.json +__pycache__/ diff --git a/tests/e2e/generate-client/examples/python-sdk/README.md b/tests/e2e/generate-client/examples/python-sdk/README.md new file mode 100644 index 0000000000..5ce15a1fe1 --- /dev/null +++ b/tests/e2e/generate-client/examples/python-sdk/README.md @@ -0,0 +1,13 @@ +# python-sdk + +The `python` generator emits `src/api/client.py` — a full Python SDK over [httpx](https://www.python-httpx.org/) (Python ≥ 3.9): +typed dataclass models, sync `Client` and async `AsyncClient`, auth, retries, pagination iterators (`_pages()` / `_items()`), SSE streaming, and multipart bodies. +No TypeScript is involved — a `python`-only selection never loads the `typescript` package. + +```sh +npm run generate +pip install httpx +python src/main.py +``` + +The example calls the live demo API at `https://api.cafe.redocly.com` and prints three menu item names. diff --git a/tests/e2e/generate-client/examples/python-sdk/package.json b/tests/e2e/generate-client/examples/python-sdk/package.json new file mode 100644 index 0000000000..2d2b76b025 --- /dev/null +++ b/tests/e2e/generate-client/examples/python-sdk/package.json @@ -0,0 +1,12 @@ +{ + "name": "@redocly-examples/python-sdk", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "generate": "redocly generate-client" + }, + "devDependencies": { + "@redocly/cli": "latest" + } +} diff --git a/tests/e2e/generate-client/examples/python-sdk/redocly.yaml b/tests/e2e/generate-client/examples/python-sdk/redocly.yaml new file mode 100644 index 0000000000..13e012a530 --- /dev/null +++ b/tests/e2e/generate-client/examples/python-sdk/redocly.yaml @@ -0,0 +1,8 @@ +# redocly.yaml — drives `redocly generate-client` for this example. +apis: + python-sdk: + root: ../_shared/cafe.yaml + clientOutput: ./src/api/client.ts + client: + generators: + - python diff --git a/tests/e2e/generate-client/examples/python-sdk/src/main.py b/tests/e2e/generate-client/examples/python-sdk/src/main.py new file mode 100644 index 0000000000..d9c40068e1 --- /dev/null +++ b/tests/e2e/generate-client/examples/python-sdk/src/main.py @@ -0,0 +1,12 @@ +# Consume the generated Python SDK: typed dataclasses over httpx. +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent / "api")) + +from client import Client + +client = Client() +menu = client.list_menu_items(limit=3) +for item in menu.items: + print(item.name) From f6ab32fb017b6e29dca83b532e63cc7ac899ed92 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 10:07:39 +0300 Subject: [PATCH 038/211] feat(client-generator): public runtime-sources entry for ejected generators --- packages/client-generator/package.json | 5 +++++ .../src/__tests__/entry-weight.test.ts | 11 +++++++++++ packages/client-generator/src/runtime-sources.ts | 12 ++++++++++++ 3 files changed, 28 insertions(+) create mode 100644 packages/client-generator/src/runtime-sources.ts diff --git a/packages/client-generator/package.json b/packages/client-generator/package.json index 8177085239..84a9d46f4f 100644 --- a/packages/client-generator/package.json +++ b/packages/client-generator/package.json @@ -16,6 +16,11 @@ "import": "./lib/generate.js", "default": "./lib/generate.js" }, + "./runtime-sources": { + "types": "./lib/runtime-sources.d.ts", + "import": "./lib/runtime-sources.js", + "default": "./lib/runtime-sources.js" + }, "./package.json": "./package.json" }, "engines": { diff --git a/packages/client-generator/src/__tests__/entry-weight.test.ts b/packages/client-generator/src/__tests__/entry-weight.test.ts index c2d257763f..10e05c3f71 100644 --- a/packages/client-generator/src/__tests__/entry-weight.test.ts +++ b/packages/client-generator/src/__tests__/entry-weight.test.ts @@ -46,3 +46,14 @@ describe('package root entry (lib/index.js)', () => { expect(dts).toMatch(/\bEnvelopeResult\b/); }); }); + +describe('runtime-sources entry (lib/runtime-sources.js)', () => { + it('statically loads only the generated source-string modules — ejected generators stay TS-free', () => { + const { files, externals } = staticGraph(join(libDir, 'runtime-sources.js')); + expect([...externals]).toEqual([]); + const outsideSources = [...files].filter( + (file) => !file.endsWith('runtime-sources.js') && !file.endsWith('-runtime-sources.js') + ); + expect(outsideSources).toEqual([]); + }); +}); diff --git a/packages/client-generator/src/runtime-sources.ts b/packages/client-generator/src/runtime-sources.ts new file mode 100644 index 0000000000..4192e8391e --- /dev/null +++ b/packages/client-generator/src/runtime-sources.ts @@ -0,0 +1,12 @@ +// The public `@redocly/client-generator/runtime-sources` entry: the embedded-runtime +// source strings for the language generators. Ejected generator files import these +// instead of baking the runtime in, so embedded-runtime fixes still arrive via +// `npm update` and the ejected file stays small and readable. Pure strings — this +// entry's import graph must stay dependency-free (guarded like the root entry). + +export { GO_RUNTIME_SOURCE } from './emitters/go-runtime-sources.js'; +export { PHP_RUNTIME_SOURCE } from './emitters/php-runtime-sources.js'; +export { + PYTHON_RUNTIME_SOURCES, + type PythonRuntimeModuleName, +} from './emitters/python-runtime-sources.js'; From a856027bd45f90ac20834497309a5f9679d28343 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 10:12:21 +0300 Subject: [PATCH 039/211] feat(client-generator): build-time eject assets for the language generators --- packages/client-generator/.gitignore | 1 + packages/client-generator/package.json | 2 +- .../scripts/generate-eject-assets.mjs | 52 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 packages/client-generator/.gitignore create mode 100644 packages/client-generator/scripts/generate-eject-assets.mjs diff --git a/packages/client-generator/.gitignore b/packages/client-generator/.gitignore new file mode 100644 index 0000000000..bb28f23849 --- /dev/null +++ b/packages/client-generator/.gitignore @@ -0,0 +1 @@ +eject-assets/generators/ diff --git a/packages/client-generator/package.json b/packages/client-generator/package.json index 84a9d46f4f..0cd64f3406 100644 --- a/packages/client-generator/package.json +++ b/packages/client-generator/package.json @@ -33,7 +33,7 @@ }, "scripts": { "examples:regen": "node scripts/regenerate-examples.mjs", - "prepare": "node scripts/generate-runtime-sources.mjs", + "prepare": "node scripts/generate-runtime-sources.mjs && node scripts/generate-eject-assets.mjs", "typecheck:examples": "node scripts/typecheck-examples.mjs" }, "license": "MIT", diff --git a/packages/client-generator/scripts/generate-eject-assets.mjs b/packages/client-generator/scripts/generate-eject-assets.mjs new file mode 100644 index 0000000000..e50d7059b7 --- /dev/null +++ b/packages/client-generator/scripts/generate-eject-assets.mjs @@ -0,0 +1,52 @@ +import { spawnSync } from 'node:child_process'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; + +// Build the ejectable generator assets: the neutral-toolkit language generators, +// type-stripped to plain ESM (comments preserved) with imports rewritten to the +// public entries, plus a provenance header and the `defineGenerator`-shaped +// default export the resolver loads. `redocly eject-generator ` copies +// these into the user's repo verbatim. +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'); +mkdirSync(outDir, { recursive: true }); + +const EJECTABLE = [ + { name: 'python', run: 'pythonGenerator', sample: 'pythonSample' }, + { name: 'go', run: 'goGenerator', sample: 'goSample' }, + { name: 'php', run: 'phpGenerator', sample: 'phpSample' }, +]; + +for (const { name, run, sample } of EJECTABLE) { + const source = readFileSync(join(pkgRoot, 'src', 'generators', `${name}.ts`), 'utf-8') + .replaceAll("'../authoring/index.js'", "'@redocly/client-generator'") + .replaceAll( + `'../emitters/${name}-runtime-sources.js'`, + "'@redocly/client-generator/runtime-sources'" + ); + const stripped = ts.transpileModule(source, { + compilerOptions: { + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + removeComments: false, + }, + }).outputText; + const header = [ + `// Ejected from @redocly/client-generator@${version} — the built-in "${name}" generator.`, + '// This file is yours: edit freely; the generated client stays machine-owned and is', + '// rebuilt by `redocly generate-client`. Newer generator versions merge in with', + '// `redocly eject-generator ' + name + ' --update`.', + '', + ].join('\n'); + const footer = `\nexport default {\n name: '${name}',\n run: ${run},\n sample: ${sample},\n};\n`; + const outFile = join(outDir, `${name}.mjs`); + writeFileSync(outFile, header + stripped + footer); + 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); + } +} From d6dce9f6028cfcaa44cf4132ea48c1f2705b6f39 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 10:14:20 +0300 Subject: [PATCH 040/211] feat(client-generator): path generators may shadow built-in names --- .../src/generators/__tests__/resolve.test.ts | 13 ++++++++----- packages/client-generator/src/generators/resolve.ts | 12 ++++++++++-- 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/client-generator/src/generators/__tests__/resolve.test.ts b/packages/client-generator/src/generators/__tests__/resolve.test.ts index d17bff8146..b798958f18 100644 --- a/packages/client-generator/src/generators/__tests__/resolve.test.ts +++ b/packages/client-generator/src/generators/__tests__/resolve.test.ts @@ -32,11 +32,14 @@ describe('resolveGenerators', () => { expect(registry.has('extra')).toBe(true); }); - it('rejects a custom generator whose name collides with a built-in', async () => { - const custom: CustomGenerator = { name: 'sdk', run: noopRun }; - await expect(resolveGenerators(['sdk'], { customGenerators: [custom] })).rejects.toThrow( - /collides/ - ); + it('a custom generator may take over a built-in name (ejected generators shadow their origin)', async () => { + const custom: CustomGenerator = { name: 'python', run: noopRun, sample: () => undefined }; + const { selected, registry } = await resolveGenerators(['python'], { + customGenerators: [custom], + }); + expect(selected).toEqual(['python']); + expect(registry.get('python')?.run).toBe(noopRun); + expect(typeof registry.get('python')?.sample).toBe('function'); }); it('rejects two custom generators with the same name', async () => { diff --git a/packages/client-generator/src/generators/resolve.ts b/packages/client-generator/src/generators/resolve.ts index d94e8de9ce..cebbecb98b 100644 --- a/packages/client-generator/src/generators/resolve.ts +++ b/packages/client-generator/src/generators/resolve.ts @@ -5,7 +5,7 @@ // default (or `generator`) export validated, and registered under its declared name. Built-ins are // seeded fresh per call (see `builtinGenerators`), so registration never mutates the built-in table. -import { isAbsoluteUrl, isPlainObject } from '@redocly/openapi-core'; +import { isAbsoluteUrl, isPlainObject, logger } from '@redocly/openapi-core'; import { isAbsolute, resolve as resolvePath } from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -72,13 +72,21 @@ function register(registry: Map, custom: CustomGene 'Invalid custom generator: expected an object with a non-empty string `name` and a `run` function (build one with `defineGenerator`).' ); } - if (registry.has(custom.name) || custom.name in BUILTIN_META) { + if (registry.has(custom.name)) { throw new NotSupportedError( `Generator name "${custom.name}" collides with an existing generator. Rename the custom generator.` ); } + // A custom generator MAY take over a built-in name — that's how an ejected + // generator replaces its origin without a config rename. Announce the takeover. + if (custom.name in BUILTIN_META) { + logger.warn( + `generate-client: custom generator "${custom.name}" takes over the built-in generator of the same name.\n` + ); + } registry.set(custom.name, { run: custom.run, + sample: custom.sample, requires: custom.requires, errorModes: custom.errorModes, dateTypes: custom.dateTypes, From 5f088f2f6f365acc512dd964c1a57d24c45c10aa Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 10:24:16 +0300 Subject: [PATCH 041/211] feat(cli): eject-generator and scaffold-generator commands --- packages/cli/scripts/build.mjs | 10 +- packages/cli/src/commands/eject-generator.ts | 176 ++++++++++++++++++ .../cli/src/commands/scaffold-generator.ts | 99 ++++++++++ packages/cli/src/index.ts | 61 ++++++ packages/cli/src/types.ts | 6 +- tests/e2e/generate-client/eject.test.ts | 136 ++++++++++++++ 6 files changed, 486 insertions(+), 2 deletions(-) create mode 100644 packages/cli/src/commands/eject-generator.ts create mode 100644 packages/cli/src/commands/scaffold-generator.ts create mode 100644 tests/e2e/generate-client/eject.test.ts diff --git a/packages/cli/scripts/build.mjs b/packages/cli/scripts/build.mjs index 305284ca48..1ecf3a2e31 100644 --- a/packages/cli/scripts/build.mjs +++ b/packages/cli/scripts/build.mjs @@ -1,5 +1,5 @@ import { build } from 'esbuild'; -import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { cpSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -93,6 +93,14 @@ writeFileSync( `Third-party software bundled in @redocly/cli\n\n${sections.join('\n\n')}\n` ); +// Ship the eject assets (generator files + the authoring AGENTS.md) inside lib/, so the +// bundled CLI finds them relative to its own module in both the repo and the published package. +cpSync( + path.join(packageDir, '..', 'client-generator', 'eject-assets'), + path.join(packageDir, 'lib', 'eject-assets'), + { recursive: true } +); + function findLicenseText(pkgRoot) { for (const filename of ['LICENSE', 'LICENSE.md', 'LICENSE.txt', 'LICENCE', 'LICENCE.md']) { const licensePath = path.join(pkgRoot, filename); diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts new file mode 100644 index 0000000000..999b439fe6 --- /dev/null +++ b/packages/cli/src/commands/eject-generator.ts @@ -0,0 +1,176 @@ +import { HandledError, logger } from '@redocly/openapi-core'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { type CommandArgs } from '../wrapper.js'; + +export type EjectGeneratorCommandArgv = { + generator?: string; + config?: string; + dir?: string; + force?: boolean; + update?: boolean; +}; + +/** The neutral-toolkit generators shipped as vendorable assets. */ +const EJECTABLE = new Set(['python', 'go', 'php']); +const TS_BUILTINS = new Set([ + 'sdk', + 'zod', + 'tanstack-query', + 'tanstack-query-vue', + 'tanstack-query-svelte', + 'tanstack-query-solid', + 'swr', + 'transformers', + 'mock', + 'cli', +]); + +const AGENTS_BEGIN = + ''; +const AGENTS_END = ''; + +/** The assets directory, resolved relative to the bundled module (repo and published alike). */ +export function ejectAssetsDir(): string { + return fileURLToPath(new URL('./eject-assets/', import.meta.url)); +} + +/** Drop or refresh `/AGENTS.md`: managed content between markers, user additions preserved. */ +function dropAgentsSkill(dir: string, assetsDir: string): void { + const template = readFileSync(join(assetsDir, 'AGENTS.md'), 'utf-8').trim(); + const managed = `${AGENTS_BEGIN}\n\n${template}\n\n${AGENTS_END}\n`; + const target = join(dir, 'AGENTS.md'); + if (!existsSync(target)) { + writeFileSync(target, managed, 'utf-8'); + return; + } + const current = readFileSync(target, 'utf-8'); + const begin = current.indexOf(AGENTS_BEGIN); + const end = current.indexOf(AGENTS_END); + if (begin === -1 || end === -1) { + logger.warn( + `generate-client: ${target} exists without the managed markers — leaving it untouched.\n` + ); + return; + } + writeFileSync( + target, + current.slice(0, begin) + managed.trimEnd() + current.slice(end + AGENTS_END.length), + 'utf-8' + ); +} + +/** 3-way merge via `git merge-file`; returns the merged text and the conflict count. */ +function threeWayMerge( + customized: string, + pristineBase: string, + pristineNew: string, + dir: string +): { merged: string; conflicts: number } { + const scratch = join(dir, '.pristine'); + const paths = { + ours: join(scratch, '.merge-ours'), + base: join(scratch, '.merge-base'), + theirs: join(scratch, '.merge-theirs'), + }; + writeFileSync(paths.ours, customized, 'utf-8'); + writeFileSync(paths.base, pristineBase, 'utf-8'); + writeFileSync(paths.theirs, pristineNew, 'utf-8'); + const result = spawnSync( + 'git', + [ + 'merge-file', + '-p', + '-L', + 'yours', + '-L', + 'ejected-from', + '-L', + 'update', + paths.ours, + paths.base, + paths.theirs, + ], + { encoding: 'utf-8' } + ); + for (const file of Object.values(paths)) rmSync(file, { force: true }); + if (result.error || result.status === null || result.status < 0) { + throw new HandledError( + '\n❌ `--update` needs `git` on PATH for the three-way merge. Alternative: eject to a temporary directory and diff by hand.\n' + ); + } + return { merged: result.stdout, conflicts: result.status }; +} + +export const handleEjectGenerator = async ({ argv }: CommandArgs) => { + const name = argv.generator ?? ''; + if (TS_BUILTINS.has(name)) { + logger.info( + `\nThe "${name}" generator is not ejectable — it is TypeScript-toolkit based.\n` + + `Customize its output instead: publisher defaults via \`client.setup\`, behavior via middleware,\n` + + `and options in \`redocly.yaml\` (see the "Customize client generation" guide).\n` + + `Ejectable generators: ${[...EJECTABLE].join(', ')}.\n` + ); + return; + } + if (!EJECTABLE.has(name)) { + throw new HandledError( + `\n❌ Unknown generator "${name}". Ejectable generators: ${[...EJECTABLE].join(', ')}.\n` + ); + } + + const assetsDir = ejectAssetsDir(); + const asset = readFileSync(join(assetsDir, 'generators', `${name}.mjs`), 'utf-8'); + const dir = resolve(argv.dir ?? './generators'); + const pristineDir = join(dir, '.pristine'); + const target = join(dir, `${name}.mjs`); + const pristine = join(pristineDir, `${name}.mjs`); + const printedTarget = relative(process.cwd(), target) || target; + + if (argv.update) { + if (!existsSync(target) || !existsSync(pristine)) { + throw new HandledError( + `\n❌ Nothing to update: ${printedTarget} (and its pristine snapshot) must exist. Eject first.\n` + ); + } + const { merged, conflicts } = threeWayMerge( + readFileSync(target, 'utf-8'), + readFileSync(pristine, 'utf-8'), + asset, + dir + ); + writeFileSync(target, merged, 'utf-8'); + writeFileSync(pristine, asset, 'utf-8'); + dropAgentsSkill(dir, assetsDir); + if (conflicts > 0) { + logger.warn( + `Updated ${printedTarget} with ${conflicts} conflict(s) — resolve the <<<<<<< markers, then regenerate.\n` + ); + } else { + logger.info(`Updated ${printedTarget} cleanly; pristine snapshot refreshed.\n`); + } + return; + } + + if (existsSync(target) && !argv.force) { + throw new HandledError( + `\n❌ ${printedTarget} already exists. Use --update to merge the newer version in, or --force to overwrite.\n` + ); + } + mkdirSync(pristineDir, { recursive: true }); + writeFileSync(target, asset, 'utf-8'); + writeFileSync(pristine, asset, 'utf-8'); + dropAgentsSkill(dir, assetsDir); + const configPath = `./${relative(process.cwd(), target).split('\\').join('/')}`; + logger.info( + `Ejected the "${name}" generator to ${printedTarget} (pristine snapshot committed alongside).\n` + + `It imports the authoring toolkit from @redocly/client-generator — install it once:\n\n` + + ` npm install --save-dev @redocly/client-generator\n\n` + + `Point your config at the file — the path entry takes over the built-in name:\n\n` + + ` client:\n generators:\n - ${configPath}\n\n` + + `The authoring guide for your agent is in ${relative(process.cwd(), join(dir, 'AGENTS.md'))}.\n` + ); +}; diff --git a/packages/cli/src/commands/scaffold-generator.ts b/packages/cli/src/commands/scaffold-generator.ts new file mode 100644 index 0000000000..b30e12bba3 --- /dev/null +++ b/packages/cli/src/commands/scaffold-generator.ts @@ -0,0 +1,99 @@ +import { HandledError, logger } from '@redocly/openapi-core'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; + +import { type CommandArgs } from '../wrapper.js'; +import { ejectAssetsDir } from './eject-generator.js'; + +export type ScaffoldGeneratorCommandArgv = { + generator?: string; + config?: string; + dir?: string; +}; + +const BUILTIN_NAMES = new Set([ + 'sdk', + 'zod', + 'tanstack-query', + 'tanstack-query-vue', + 'tanstack-query-svelte', + 'tanstack-query-solid', + 'swr', + 'transformers', + 'mock', + 'cli', + 'python', + 'go', + 'php', +]); + +function skeleton(name: string): string { + return `// A custom generator scaffolded by \`redocly scaffold-generator\`. +// It runs from the \`generators\` list in redocly.yaml and emits files next to the +// configured client output. The authoring guide for your agent is in ./AGENTS.md; +// the deep reference is the "Customize client generation" guide in the Redocly docs. +import { CodeWriter, identifierFor } from '@redocly/client-generator'; + +export default { + name: '${name}', + + /** + * @param {{ model: import('@redocly/client-generator').ApiModel, outputPath: string }} input + * @returns {{ path: string, content: string }[]} + */ + run({ model, outputPath }) { + const writer = new CodeWriter(' '); + writer.line(\`// \${model.title} \${model.version} — generated by the "${name}" generator.\`); + for (const service of model.services) { + for (const op of service.operations) { + // Every operation of the API description; \`op.name\` is a sanitized identifier, + // \`op.pathParams\`/\`op.queryParams\`/\`op.requestBody\` describe its inputs. + writer.line(\`// \${op.method.toUpperCase()} \${op.path} — \${identifierFor(op.name)}\`); + } + } + return [{ path: outputPath.replace(/\\.[^.]+$/, '.${name}.txt'), content: writer.toString() }]; + }, +}; +`; +} + +export const handleScaffoldGenerator = async ({ + argv, +}: CommandArgs) => { + const name = argv.generator ?? ''; + if (!/^[a-z][a-z0-9-]*$/.test(name)) { + throw new HandledError( + `\n❌ Generator name must be kebab-case (got "${name}"). Example: redocly scaffold-generator route-map\n` + ); + } + if (BUILTIN_NAMES.has(name)) { + throw new HandledError( + `\n❌ "${name}" is a built-in generator — use \`redocly eject-generator ${name}\` to vendor it, or pick another name.\n` + ); + } + const dir = resolve(argv.dir ?? './generators'); + const target = join(dir, `${name}.mjs`); + if (existsSync(target)) { + throw new HandledError(`\n❌ ${relative(process.cwd(), target)} already exists.\n`); + } + mkdirSync(dir, { recursive: true }); + writeFileSync(target, skeleton(name), 'utf-8'); + + // The same AGENTS.md drop the eject command performs (markers keep user additions safe). + const template = readFileSync(join(ejectAssetsDir(), 'AGENTS.md'), 'utf-8').trim(); + const agents = join(dir, 'AGENTS.md'); + if (!existsSync(agents)) { + writeFileSync( + agents, + `\n\n${template}\n\n\n`, + 'utf-8' + ); + } + + const configPath = `./${relative(process.cwd(), target).split('\\').join('/')}`; + logger.info( + `Scaffolded ${relative(process.cwd(), target)}.\n` + + `Add it to your config and run \`redocly generate-client\`:\n\n` + + ` client:\n generators:\n - sdk\n - ${configPath}\n` + ); +}; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 894cb5d568..075fd4d682 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -18,6 +18,10 @@ import { handleBundle } from './commands/bundle.js'; import type { ReportFormat } from './commands/drift/engine/reporter.js'; import { type DriftArgv } from './commands/drift/index.js'; import type { FindingSeverity, MatchMode, TrafficFormat } from './commands/drift/types/index.js'; +import { + handleEjectGenerator, + type EjectGeneratorCommandArgv, +} from './commands/eject-generator.js'; import { handleEject, type EjectArgv } from './commands/eject.js'; import { handleGenerateArazzo, @@ -35,6 +39,10 @@ import { previewProject } from './commands/preview-project/index.js'; import { type ProxyArgv } from './commands/proxy/index.js'; import { handleRespect, type RespectArgv } from './commands/respect/index.js'; import { validateMtlsCommandOption } from './commands/respect/mtls/validate-mtls-command-option.js'; +import { + handleScaffoldGenerator, + type ScaffoldGeneratorCommandArgv, +} from './commands/scaffold-generator.js'; import { handleScore } from './commands/score/index.js'; import { handleScorecardClassic } from './commands/scorecard-classic/index.js'; import type { @@ -955,6 +963,59 @@ yargs(hideBin(process.argv)) commandWrapper(handleGenerateClient)(argv as Arguments); } ) + .command( + 'eject-generator [generator]', + 'Vendor a built-in client generator into your repo as an editable file [experimental].', + (yargs) => + yargs + .positional('generator', { + describe: 'Built-in generator to eject (python, go, php).', + type: 'string', + }) + .options({ + dir: { + describe: 'Directory to eject into.', + type: 'string', + default: './generators', + requiresArg: true, + }, + force: { + describe: 'Overwrite an existing ejected file (discards local edits).', + type: 'boolean', + default: false, + }, + update: { + describe: + 'Three-way merge a newer generator version into your customized copy (pristine × new × yours).', + type: 'boolean', + default: false, + }, + }), + async (argv) => { + commandWrapper(handleEjectGenerator)(argv as Arguments); + } + ) + .command( + 'scaffold-generator [generator]', + 'Create a custom client-generator skeleton plus the authoring guide (AGENTS.md) [experimental].', + (yargs) => + yargs + .positional('generator', { + describe: 'Name for the new generator (kebab-case).', + type: 'string', + }) + .options({ + dir: { + describe: 'Directory to scaffold into.', + type: 'string', + default: './generators', + requiresArg: true, + }, + }), + async (argv) => { + commandWrapper(handleScaffoldGenerator)(argv as Arguments); + } + ) .command( 'generate-spec ', 'Infer an OpenAPI description from recorded HTTP traffic, optionally refined with AI [experimental].', diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 14b2a0f2da..a5ddeef5bb 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -4,6 +4,7 @@ import type { LoginArgv, LogoutArgv } from './commands/auth.js'; import type { BuildDocsArgv } from './commands/build-docs/types.js'; import type { BundleArgv } from './commands/bundle.js'; import type { DriftArgv } from './commands/drift/index.js'; +import type { EjectGeneratorCommandArgv } from './commands/eject-generator.js'; import type { EjectArgv } from './commands/eject.js'; import type { GenerateArazzoCommandArgv } from './commands/generate-arazzo.js'; import type { JoinArgv } from './commands/join/types.js'; @@ -11,6 +12,7 @@ import type { LintArgv } from './commands/lint.js'; import type { PreviewProjectArgv } from './commands/preview-project/types.js'; import type { ProxyArgv } from './commands/proxy/index.js'; import type { RespectArgv } from './commands/respect/index.js'; +import type { ScaffoldGeneratorCommandArgv } from './commands/scaffold-generator.js'; import type { SplitArgv } from './commands/split/types.js'; import type { StatsArgv } from './commands/stats/index.js'; import type { TranslationsArgv } from './commands/translations.js'; @@ -46,7 +48,9 @@ export type CommandArgv = | RespectArgv | DriftArgv | ProxyArgv - | GenerateArazzoCommandArgv; + | GenerateArazzoCommandArgv + | EjectGeneratorCommandArgv + | ScaffoldGeneratorCommandArgv; export type VerifyConfigOptions = { config?: string; diff --git a/tests/e2e/generate-client/eject.test.ts b/tests/e2e/generate-client/eject.test.ts new file mode 100644 index 0000000000..828377a9ec --- /dev/null +++ b/tests/e2e/generate-client/eject.test.ts @@ -0,0 +1,136 @@ +import { spawnSync } from 'node:child_process'; +import { + appendFileSync, + copyFileSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { cliEntry, repoRoot } from './helpers.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** A throwaway project where `@redocly/client-generator` resolves like a user install. */ +function makeProject(): string { + const dir = mkdtempSync(join(tmpdir(), 'eject-')); + copyFileSync(join(__dirname, 'fixtures/pagination.yaml'), join(dir, 'openapi.yaml')); + mkdirSync(join(dir, 'node_modules/@redocly'), { recursive: true }); + symlinkSync( + join(repoRoot, 'packages/client-generator'), + join(dir, 'node_modules/@redocly/client-generator') + ); + return dir; +} + +function run(cwd: string, args: string[]) { + return spawnSync('node', [cliEntry, ...args], { cwd, encoding: 'utf-8' }); +} + +describe('eject-generator / scaffold-generator (end-to-end)', () => { + let project: string; + + beforeAll(() => { + project = makeProject(); + }); + + afterAll(() => { + rmSync(project, { recursive: true, force: true }); + }); + + it('ejects php: file + pristine snapshot + AGENTS.md, and re-eject without --force errors', () => { + const eject = run(project, ['eject-generator', 'php']); + expect(eject.status, eject.stderr).toBe(0); + expect(existsSync(join(project, 'generators/php.mjs'))).toBe(true); + expect(existsSync(join(project, 'generators/.pristine/php.mjs'))).toBe(true); + expect(readFileSync(join(project, 'generators/AGENTS.md'), 'utf-8')).toContain( + 'redocly-generators:begin' + ); + expect(run(project, ['eject-generator', 'php']).status).not.toBe(0); + expect(run(project, ['eject-generator', 'php', '--force']).status).toBe(0); + }); + + it('THE headline: an ejected-unmodified generator produces byte-identical output', () => { + const builtin = run(project, [ + 'generate-client', + 'openapi.yaml', + '--output', + 'builtin/client.ts', + '--generator', + 'php', + ]); + expect(builtin.status, builtin.stderr).toBe(0); + const ejected = run(project, [ + 'generate-client', + 'openapi.yaml', + '--output', + 'ejected/client.ts', + '--generator', + './generators/php.mjs', + ]); + expect(ejected.status, ejected.stderr).toBe(0); + expect(ejected.stderr).toContain('takes over the built-in generator'); + expect(readFileSync(join(project, 'ejected/client.php'), 'utf-8')).toBe( + readFileSync(join(project, 'builtin/client.php'), 'utf-8') + ); + }); + + it('sdk prints guidance instead of ejecting; unknown names error', () => { + const sdk = run(project, ['eject-generator', 'sdk']); + expect(sdk.status).toBe(0); + expect(sdk.stderr + sdk.stdout).toContain('not ejectable'); + expect(existsSync(join(project, 'generators/sdk.mjs'))).toBe(false); + expect(run(project, ['eject-generator', 'nowhere']).status).not.toBe(0); + }); + + it('--update merges cleanly around local edits and marks real conflicts', () => { + appendFileSync(join(project, 'generators/php.mjs'), '// my local customization\n'); + const clean = run(project, ['eject-generator', 'php', '--update']); + expect(clean.status, clean.stderr).toBe(0); + expect(readFileSync(join(project, 'generators/php.mjs'), 'utf-8')).toContain( + '// my local customization' + ); + + // Diverge the same first line in the pristine base and the user copy: a true conflict. + for (const [file, line] of [ + ['generators/.pristine/php.mjs', '// OLD pristine line'], + ['generators/php.mjs', '// USER edited line'], + ] as const) { + const path = join(project, file); + const lines = readFileSync(path, 'utf-8').split('\n'); + lines[0] = line; + writeFileSync(path, lines.join('\n'), 'utf-8'); + } + const conflicted = run(project, ['eject-generator', 'php', '--update']); + expect(conflicted.status, conflicted.stderr).toBe(0); + expect(conflicted.stderr + conflicted.stdout).toContain('conflict'); + expect(readFileSync(join(project, 'generators/php.mjs'), 'utf-8')).toContain('<<<<<<<'); + }); + + it('scaffold-generator creates a runnable skeleton; built-in names are refused', () => { + const scaffold = run(project, ['scaffold-generator', 'route-map']); + expect(scaffold.status, scaffold.stderr).toBe(0); + const generate = run(project, [ + 'generate-client', + 'openapi.yaml', + '--output', + 'scaffolded/client.ts', + '--generator', + 'sdk', + '--generator', + './generators/route-map.mjs', + ]); + expect(generate.status, generate.stderr).toBe(0); + expect(readFileSync(join(project, 'scaffolded/client.route-map.txt'), 'utf-8')).toContain( + 'GET /orders — listOrders' + ); + expect(run(project, ['scaffold-generator', 'php']).status).not.toBe(0); + }); +}); From c5d324ab90cc342505e00163e4be97a6b65b562c Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 10:44:09 +0300 Subject: [PATCH 042/211] docs(client-generator): eject and scaffold docs, sidebar, changeset --- .changeset/eject-scaffold-generators.md | 6 +++ docs/@v2/commands/eject-generator.md | 53 +++++++++++++++++++ docs/@v2/commands/index.md | 2 + docs/@v2/commands/scaffold-generator.md | 34 ++++++++++++ .../@v2/guides/customize-client-generation.md | 10 ++++ docs/@v2/v2.sidebars.yaml | 4 ++ tests/e2e/generate-client/eject.test.ts | 14 ++--- 7 files changed, 116 insertions(+), 7 deletions(-) create mode 100644 .changeset/eject-scaffold-generators.md create mode 100644 docs/@v2/commands/eject-generator.md create mode 100644 docs/@v2/commands/scaffold-generator.md diff --git a/.changeset/eject-scaffold-generators.md b/.changeset/eject-scaffold-generators.md new file mode 100644 index 0000000000..8c0c9d6ba7 --- /dev/null +++ b/.changeset/eject-scaffold-generators.md @@ -0,0 +1,6 @@ +--- +'@redocly/client-generator': minor +'@redocly/cli': minor +--- + +Added `redocly eject-generator` and `redocly scaffold-generator` — vendor a built-in language generator (`python`, `go`, `php`) into your repo as an editable file (with a pristine snapshot, three-way `--update` merges, and byte-identical output when unmodified), or scaffold a custom generator skeleton; both drop the `AGENTS.md` generator-authoring guide for coding agents. A path-loaded generator may now take over a built-in name, and the new `@redocly/client-generator/runtime-sources` entry serves the embedded-runtime sources to ejected generators. diff --git a/docs/@v2/commands/eject-generator.md b/docs/@v2/commands/eject-generator.md new file mode 100644 index 0000000000..e1b9c97454 --- /dev/null +++ b/docs/@v2/commands/eject-generator.md @@ -0,0 +1,53 @@ +# `eject-generator` + +## Introduction + +The `eject-generator` command vendors a built-in client generator into your repo as an editable file — the generator becomes yours to customize, while the _generated_ client stays machine-owned and reproducible. +Your agent (or you) edits the generator, `redocly generate-client` rebuilds the client, and next week's spec change regenerates with the customization intact. + +Ejectable generators: `python`, `go`, `php` — the language generators built on the language-neutral authoring toolkit. +The TypeScript `sdk` and its satellite generators are customized through `client.setup`, middleware, and configuration instead; running `eject-generator sdk` prints that guidance. + +## Usage + +```bash +redocly eject-generator python +redocly eject-generator go --dir ./generators +redocly eject-generator php --update +redocly eject-generator php --force +``` + +## Options + +| Option | Type | Description | +| ---------- | ------- | ---------------------------------------------------------------------------------------------------- | +| generator | string | Built-in generator to eject: `python`, `go`, or `php`. | +| `--dir` | string | Directory to eject into. Default `./generators`. | +| `--update` | boolean | Three-way merge a newer generator version into your customized copy; conflicts get standard markers. | +| `--force` | boolean | Overwrite an existing ejected file, discarding local edits. | + +## How it works + +Ejecting writes three things: + +- `/.mjs` — the generator, the exact code the built-in runs, readable plain ESM. +- `/.pristine/.mjs` — a pristine snapshot (commit it); `--update` uses it as the merge base. +- `/AGENTS.md` — the generator-authoring guide for your coding agent, marker-delimited so your own additions survive refreshes. + +The ejected file imports the authoring toolkit, so install it once: + +```bash +npm install --save-dev @redocly/client-generator +``` + +Then point your config at the file — a path entry takes over the built-in name: + +```yaml +client: + generators: + - ./generators/python.mjs +``` + +An ejected-unmodified generator produces byte-identical output to the built-in. +To roll back, delete the file and restore the config line. +Not ejected means managed: without ejecting, generator improvements arrive via `npm update` with nothing to merge. diff --git a/docs/@v2/commands/index.md b/docs/@v2/commands/index.md index 3d4107239e..4b60a30131 100644 --- a/docs/@v2/commands/index.md +++ b/docs/@v2/commands/index.md @@ -15,6 +15,8 @@ API management commands: - [`bundle`](bundle.md) Bundle API description. - [`generate-client`](generate-client.md) Generate a typed TypeScript client from an OpenAPI description [experimental feature]. +- [`eject-generator`](eject-generator.md) Vendor a built-in client generator into your repo as an editable file [experimental feature]. +- [`scaffold-generator`](scaffold-generator.md) Create a custom client-generator skeleton plus the authoring guide [experimental feature]. - [`join`](join.md) Join API descriptions [experimental feature]. - [`score`](score.md) Score an API for integration simplicity and AI agent readiness. - [`split`](split.md) Split API description into a multi-file structure. diff --git a/docs/@v2/commands/scaffold-generator.md b/docs/@v2/commands/scaffold-generator.md new file mode 100644 index 0000000000..ef61c84aa0 --- /dev/null +++ b/docs/@v2/commands/scaffold-generator.md @@ -0,0 +1,34 @@ +# `scaffold-generator` + +## Introduction + +The `scaffold-generator` command creates a custom client-generator skeleton — for emitting an artifact no built-in generator covers (a route map, a facade, an SDK in another language). +It also drops `AGENTS.md`, the authoring guide that teaches your coding agent the generator contract, the API model shape, and the language-neutral helpers. + +## Usage + +```bash +redocly scaffold-generator route-map +redocly scaffold-generator my-sdk --dir ./generators +``` + +## Options + +| Option | Type | Description | +| --------- | ------ | -------------------------------------------------------------------- | +| generator | string | Name for the new generator (kebab-case; built-in names are refused). | +| `--dir` | string | Directory to scaffold into. Default `./generators`. | + +## How it works + +The skeleton is a runnable generator: it walks every operation of the API description and emits one file. +Replace its body with your output logic — the `CodeWriter`, naming, and schema helpers from `@redocly/client-generator` (installed once as a dev dependency) handle indentation, identifier sanitization, and schema semantics in any output language. + +```yaml +client: + generators: + - sdk + - ./generators/route-map.mjs +``` + +To vendor and customize a built-in language generator instead, use [`eject-generator`](./eject-generator.md). diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index 80efb36786..bde2e23f3b 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -61,6 +61,16 @@ Express un-bypassable behavior as middleware, not a custom `fetch`. A setup file may import **only** from `@redocly/client-generator`. See the [`baked-setup` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/baked-setup). +## Eject and scaffold + +The fastest paths to a customized generator are the two commands: + +- [`redocly eject-generator `](../commands/eject-generator.md) vendors a built-in language generator (`python`, `go`, `php`) into `./generators/` as an editable file, with a pristine snapshot for [three-way updates](../commands/eject-generator.md#how-it-works) and the `AGENTS.md` authoring guide for your coding agent. + An ejected-unmodified generator produces byte-identical output, and the path entry takes over the built-in name — regeneration survives every customization. +- [`redocly scaffold-generator `](../commands/scaffold-generator.md) creates a runnable skeleton for an artifact no built-in covers. + +Both drop `AGENTS.md` next to the generator: your agent reads it to learn the model shape, the helper library, and the verify loop (edit the generator → `redocly generate-client` → review the client diff — generated files are never hand-edited). + ## Custom generators The built-in generators cover common targets. diff --git a/docs/@v2/v2.sidebars.yaml b/docs/@v2/v2.sidebars.yaml index 5d2155c3ec..4d747665d5 100644 --- a/docs/@v2/v2.sidebars.yaml +++ b/docs/@v2/v2.sidebars.yaml @@ -18,6 +18,8 @@ page: commands/drift.md - label: eject page: commands/eject.md + - label: eject-generator + page: commands/eject-generator.md - label: generate-arazzo page: commands/generate-arazzo.md - label: generate-client @@ -42,6 +44,8 @@ page: commands/push-status.md - label: respect page: commands/respect.md + - label: scaffold-generator + page: commands/scaffold-generator.md - label: score page: commands/score.md - label: scorecard-classic diff --git a/tests/e2e/generate-client/eject.test.ts b/tests/e2e/generate-client/eject.test.ts index 828377a9ec..8b92c35045 100644 --- a/tests/e2e/generate-client/eject.test.ts +++ b/tests/e2e/generate-client/eject.test.ts @@ -39,11 +39,11 @@ describe('eject-generator / scaffold-generator (end-to-end)', () => { beforeAll(() => { project = makeProject(); - }); + }, 60_000); afterAll(() => { rmSync(project, { recursive: true, force: true }); - }); + }, 60_000); it('ejects php: file + pristine snapshot + AGENTS.md, and re-eject without --force errors', () => { const eject = run(project, ['eject-generator', 'php']); @@ -55,7 +55,7 @@ describe('eject-generator / scaffold-generator (end-to-end)', () => { ); expect(run(project, ['eject-generator', 'php']).status).not.toBe(0); expect(run(project, ['eject-generator', 'php', '--force']).status).toBe(0); - }); + }, 60_000); it('THE headline: an ejected-unmodified generator produces byte-identical output', () => { const builtin = run(project, [ @@ -80,7 +80,7 @@ describe('eject-generator / scaffold-generator (end-to-end)', () => { expect(readFileSync(join(project, 'ejected/client.php'), 'utf-8')).toBe( readFileSync(join(project, 'builtin/client.php'), 'utf-8') ); - }); + }, 60_000); it('sdk prints guidance instead of ejecting; unknown names error', () => { const sdk = run(project, ['eject-generator', 'sdk']); @@ -88,7 +88,7 @@ describe('eject-generator / scaffold-generator (end-to-end)', () => { expect(sdk.stderr + sdk.stdout).toContain('not ejectable'); expect(existsSync(join(project, 'generators/sdk.mjs'))).toBe(false); expect(run(project, ['eject-generator', 'nowhere']).status).not.toBe(0); - }); + }, 60_000); it('--update merges cleanly around local edits and marks real conflicts', () => { appendFileSync(join(project, 'generators/php.mjs'), '// my local customization\n'); @@ -112,7 +112,7 @@ describe('eject-generator / scaffold-generator (end-to-end)', () => { expect(conflicted.status, conflicted.stderr).toBe(0); expect(conflicted.stderr + conflicted.stdout).toContain('conflict'); expect(readFileSync(join(project, 'generators/php.mjs'), 'utf-8')).toContain('<<<<<<<'); - }); + }, 60_000); it('scaffold-generator creates a runnable skeleton; built-in names are refused', () => { const scaffold = run(project, ['scaffold-generator', 'route-map']); @@ -132,5 +132,5 @@ describe('eject-generator / scaffold-generator (end-to-end)', () => { 'GET /orders — listOrders' ); expect(run(project, ['scaffold-generator', 'php']).status).not.toBe(0); - }); + }, 60_000); }); From 9e671dc1250eae31d31ddaef281fa2f89cddc224 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 11:53:07 +0300 Subject: [PATCH 043/211] feat(cli): eject and ejected-generator telemetry with coarse outcome categories --- .changeset/eject-telemetry.md | 6 +++ docs/@v2/usage-data.md | 3 ++ .../commands/eject-generator.test.ts | 49 +++++++++++++++++++ .../generate-client-telemetry.test.ts | 30 ++++++++++++ packages/cli/src/commands/eject-generator.ts | 16 ++++++ packages/cli/src/commands/generate-client.ts | 8 ++- .../cli/src/commands/scaffold-generator.ts | 8 +++ .../src/utils/generate-client-telemetry.ts | 33 +++++++++++++ packages/cli/src/utils/telemetry.ts | 17 ++++++- packages/cli/src/wrapper.ts | 6 ++- packages/client-generator/src/pipeline.ts | 21 +++++--- 11 files changed, 187 insertions(+), 10 deletions(-) create mode 100644 .changeset/eject-telemetry.md create mode 100644 packages/cli/src/__tests__/commands/eject-generator.test.ts diff --git a/.changeset/eject-telemetry.md b/.changeset/eject-telemetry.md new file mode 100644 index 0000000000..727560a28c --- /dev/null +++ b/.changeset/eject-telemetry.md @@ -0,0 +1,6 @@ +--- +'@redocly/client-generator': patch +'@redocly/cli': patch +--- + +Added coarse usage telemetry for the eject workflow (respecting the `REDOCLY_TELEMETRY` opt-out, documented on the usage-data page): `eject-generator`/`scaffold-generator` report the action and outcome category (such as clean or conflicted `--update` merges), `generate-client` reports the built-in origin and ejected-from version of path generators that carry the eject provenance header, and a generator that throws during a run is now reported as the `generator-run` error category with the failing generator named in the CLI error message. File contents, paths, and user-chosen names are never transmitted. diff --git a/docs/@v2/usage-data.md b/docs/@v2/usage-data.md index e588a35211..49a1319419 100644 --- a/docs/@v2/usage-data.md +++ b/docs/@v2/usage-data.md @@ -22,6 +22,9 @@ When a command is run, the following data is collected: - names of lint rules that reported errors, warnings, or ignored problems - Arazzo x-security authentication types - for `generate-client`: which built-in generators ran, the count of custom generators, which of the package's own exported helper names a custom generator imports, and a coarse error category on failure. + When a path-loaded generator carries the `eject-generator` provenance header, its built-in origin and the version it was ejected from are included (for example `php@0.2.0`) — the file's contents, path, and any user-chosen names are never transmitted. +- for `eject-generator` and `scaffold-generator`: the action (`eject`, `update`, `guidance`, `scaffold`), the built-in generator name for eject actions, and a coarse outcome category (such as `success`, `conflicts` with the conflict count, `already-exists`, or `merge-tool-missing`). + A scaffolded generator's name is user-chosen and is never transmitted. Custom generator file contents, paths, and names are never collected. - platform (Linux, macOS, Windows) - anonymous ID (a randomly generated identifier that doesn't contain personal information) diff --git a/packages/cli/src/__tests__/commands/eject-generator.test.ts b/packages/cli/src/__tests__/commands/eject-generator.test.ts new file mode 100644 index 0000000000..51c1a6e6e4 --- /dev/null +++ b/packages/cli/src/__tests__/commands/eject-generator.test.ts @@ -0,0 +1,49 @@ +import { handleEjectGenerator } from '../../commands/eject-generator.js'; +import { handleScaffoldGenerator } from '../../commands/scaffold-generator.js'; +import { ejectGeneratorTelemetry } from '../../utils/generate-client-telemetry.js'; +import type { CommandArgs } from '../../wrapper.js'; + +const baseArgs = { version: '0.0.0', config: undefined } as unknown as Omit< + CommandArgs>, + 'argv' +>; + +function reset() { + for (const key of Object.keys(ejectGeneratorTelemetry)) { + delete ejectGeneratorTelemetry[key as keyof typeof ejectGeneratorTelemetry]; + } +} + +describe('eject/scaffold telemetry (coarse categories only)', () => { + beforeEach(reset); + + it('sdk guidance records the allowlisted name and a guidance action', async () => { + await handleEjectGenerator({ ...baseArgs, argv: { generator: 'sdk' } } as CommandArgs); + expect(ejectGeneratorTelemetry).toEqual({ + eject_generator_action: 'guidance', + eject_generator_name: 'sdk', + eject_generator_outcome: 'success', + }); + }); + + it('an unknown generator records the outcome but never the user-supplied name', async () => { + await expect( + handleEjectGenerator({ + ...baseArgs, + argv: { generator: 'my-secret-internal-api' }, + } as CommandArgs) + ).rejects.toThrow(/Unknown generator/); + expect(ejectGeneratorTelemetry.eject_generator_outcome).toBe('unknown-generator'); + expect(ejectGeneratorTelemetry.eject_generator_name).toBeUndefined(); + }); + + it('scaffolding a built-in name records the refusal, not the name', async () => { + await expect( + handleScaffoldGenerator({ ...baseArgs, argv: { generator: 'php' } } as CommandArgs) + ).rejects.toThrow(/built-in generator/); + expect(ejectGeneratorTelemetry).toEqual({ + eject_generator_action: 'scaffold', + eject_generator_outcome: 'builtin-name', + }); + }); +}); diff --git a/packages/cli/src/__tests__/generate-client-telemetry.test.ts b/packages/cli/src/__tests__/generate-client-telemetry.test.ts index a25bbeadbb..75deb3b99d 100644 --- a/packages/cli/src/__tests__/generate-client-telemetry.test.ts +++ b/packages/cli/src/__tests__/generate-client-telemetry.test.ts @@ -1,6 +1,8 @@ import { + BUILTIN_GENERATOR_NAMES, categorizeGenerateClientError, collectToolkitImports, + parseEjectedProvenance, } from '../utils/generate-client-telemetry.js'; describe('collectToolkitImports', () => { @@ -36,5 +38,33 @@ describe('categorizeGenerateClientError', () => { categorizeGenerateClientError('The "swr" generator does not support --error-mode "result"') ).toBe('not-supported'); expect(categorizeGenerateClientError('boom')).toBe('other'); + expect(categorizeGenerateClientError('Generator "php" failed: something broke')).toBe( + 'generator-run' + ); + }); +}); + +describe('BUILTIN_GENERATOR_NAMES', () => { + it('covers every current built-in — a missing name silently degrades the usage event', () => { + for (const name of ['sdk', 'zod', 'mock', 'cli', 'python', 'go', 'php']) { + expect(BUILTIN_GENERATOR_NAMES.has(name), name).toBe(true); + } + }); +}); + +describe('parseEjectedProvenance', () => { + it('reads OUR provenance header — an allowlisted name and version, nothing user-authored', () => { + const source = + '// Ejected from @redocly/client-generator@0.2.0 — the built-in "php" generator.\n// rest…'; + expect(parseEjectedProvenance(source)).toEqual({ name: 'php', version: '0.2.0' }); + }); + + it('returns undefined for non-ejected files and non-allowlisted names', () => { + expect(parseEjectedProvenance('export default { name: "mine", run() {} }')).toBeUndefined(); + expect( + parseEjectedProvenance( + '// Ejected from @redocly/client-generator@0.2.0 — the built-in "evil()" generator.' + ) + ).toBeUndefined(); }); }); diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts index 999b439fe6..cb6c03abed 100644 --- a/packages/cli/src/commands/eject-generator.ts +++ b/packages/cli/src/commands/eject-generator.ts @@ -4,6 +4,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node import { join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { ejectGeneratorTelemetry } from '../utils/generate-client-telemetry.js'; import { type CommandArgs } from '../wrapper.js'; export type EjectGeneratorCommandArgv = { @@ -98,6 +99,7 @@ function threeWayMerge( ); for (const file of Object.values(paths)) rmSync(file, { force: true }); if (result.error || result.status === null || result.status < 0) { + ejectGeneratorTelemetry.eject_generator_outcome = 'merge-tool-missing'; throw new HandledError( '\n❌ `--update` needs `git` on PATH for the three-way merge. Alternative: eject to a temporary directory and diff by hand.\n' ); @@ -107,7 +109,15 @@ function threeWayMerge( export const handleEjectGenerator = async ({ argv }: CommandArgs) => { const name = argv.generator ?? ''; + // Coarse usage telemetry: our command action, an ALLOWLISTED built-in name, and the + // outcome category — never user paths, file contents, or user-chosen names. + ejectGeneratorTelemetry.eject_generator_action = argv.update ? 'update' : 'eject'; + if (EJECTABLE.has(name) || TS_BUILTINS.has(name)) { + ejectGeneratorTelemetry.eject_generator_name = name; + } if (TS_BUILTINS.has(name)) { + ejectGeneratorTelemetry.eject_generator_action = 'guidance'; + ejectGeneratorTelemetry.eject_generator_outcome = 'success'; logger.info( `\nThe "${name}" generator is not ejectable — it is TypeScript-toolkit based.\n` + `Customize its output instead: publisher defaults via \`client.setup\`, behavior via middleware,\n` + @@ -117,6 +127,7 @@ export const handleEjectGenerator = async ({ argv }: CommandArgs 0 ? 'conflicts' : 'success'; if (conflicts > 0) { + ejectGeneratorTelemetry.eject_generator_conflicts = conflicts; logger.warn( `Updated ${printedTarget} with ${conflicts} conflict(s) — resolve the <<<<<<< markers, then regenerate.\n` ); @@ -156,6 +170,7 @@ export const handleEjectGenerator = async ({ argv }: CommandArgs) => { const name = argv.generator ?? ''; + // Coarse usage telemetry: action + outcome category only — a scaffolded generator's + // name is user-chosen and never transmitted. + ejectGeneratorTelemetry.eject_generator_action = 'scaffold'; if (!/^[a-z][a-z0-9-]*$/.test(name)) { + ejectGeneratorTelemetry.eject_generator_outcome = 'invalid-name'; throw new HandledError( `\n❌ Generator name must be kebab-case (got "${name}"). Example: redocly scaffold-generator route-map\n` ); } if (BUILTIN_NAMES.has(name)) { + ejectGeneratorTelemetry.eject_generator_outcome = 'builtin-name'; throw new HandledError( `\n❌ "${name}" is a built-in generator — use \`redocly eject-generator ${name}\` to vendor it, or pick another name.\n` ); @@ -74,6 +80,7 @@ export const handleScaffoldGenerator = async ({ const dir = resolve(argv.dir ?? './generators'); const target = join(dir, `${name}.mjs`); if (existsSync(target)) { + ejectGeneratorTelemetry.eject_generator_outcome = 'already-exists'; throw new HandledError(`\n❌ ${relative(process.cwd(), target)} already exists.\n`); } mkdirSync(dir, { recursive: true }); @@ -90,6 +97,7 @@ export const handleScaffoldGenerator = async ({ ); } + ejectGeneratorTelemetry.eject_generator_outcome = 'success'; const configPath = `./${relative(process.cwd(), target).split('\\').join('/')}`; logger.info( `Scaffolded ${relative(process.cwd(), target)}.\n` + diff --git a/packages/cli/src/utils/generate-client-telemetry.ts b/packages/cli/src/utils/generate-client-telemetry.ts index 6865b95ce0..32c6d3dd38 100644 --- a/packages/cli/src/utils/generate-client-telemetry.ts +++ b/packages/cli/src/utils/generate-client-telemetry.ts @@ -8,6 +8,8 @@ export type GenerateClientTelemetry = { generate_client_custom_generators_count?: number; generate_client_toolkit_imports?: string[]; generate_client_error_category?: string; + /** Ejected built-ins in the run, as `@` (from OUR provenance header). */ + generate_client_ejected_generators?: string[]; }; /** Populated by handleGenerateClient; spread into the telemetry payload by the wrapper. */ @@ -24,8 +26,10 @@ export const BUILTIN_GENERATOR_NAMES = new Set([ 'swr', 'transformers', 'mock', + 'cli', 'python', 'go', + 'php', ]); const IMPORT_RE = @@ -48,12 +52,41 @@ export function collectToolkitImports(source: string, knownHelpers: readonly str return [...found]; } +const PROVENANCE_RE = + /^\/\/ Ejected from @redocly\/client-generator@([\w.-]+) — the built-in "([a-z-]+)" generator\./; + +/** + * The ``/`` from OUR eject provenance header, when the file carries one + * and the name is allowlisted — an ejected generator's origin, never user-authored text. + */ +export function parseEjectedProvenance( + source: string +): { name: string; version: string } | undefined { + const match = source.match(PROVENANCE_RE); + if (!match || !BUILTIN_GENERATOR_NAMES.has(match[2])) return undefined; + return { name: match[2], version: match[1] }; +} + /** Coarse category from an error message — never the message itself. */ export function categorizeGenerateClientError(message: string): string { if (message.includes('Invalid pagination configuration')) return 'pagination'; if (message.includes('Could not load generator')) return 'generator-load'; + if (/^Generator "[^"]+" failed:/.test(message)) return 'generator-run'; if (message.includes('Unknown generator') || message.includes('does not support')) { return 'not-supported'; } return 'other'; } + +export type EjectGeneratorTelemetry = { + /** 'eject' | 'update' | 'guidance' | 'scaffold'. */ + eject_generator_action?: string; + /** Allowlisted built-in name only; scaffold and unknown names stay unnamed. */ + eject_generator_name?: string; + /** Coarse outcome: success | conflicts | already-exists | missing-pristine | merge-tool-missing | unknown-generator | builtin-name | invalid-name. */ + eject_generator_outcome?: string; + eject_generator_conflicts?: number; +}; + +/** Populated by the eject/scaffold handlers; spread into the telemetry payload by the wrapper. */ +export const ejectGeneratorTelemetry: EjectGeneratorTelemetry = {}; diff --git a/packages/cli/src/utils/telemetry.ts b/packages/cli/src/utils/telemetry.ts index 1662899e00..69d6617433 100644 --- a/packages/cli/src/utils/telemetry.ts +++ b/packages/cli/src/utils/telemetry.ts @@ -22,7 +22,10 @@ import type { CriterionObject } from '../../../core/src/typings/arazzo.js'; import { getReuniteUrl } from '../reunite/api/index.js'; import type { CommandArgv } from '../types.js'; import { ANONYMOUS_ID_CACHE_FILE } from './constants.js'; -import type { GenerateClientTelemetry } from './generate-client-telemetry.js'; +import type { + EjectGeneratorTelemetry, + GenerateClientTelemetry, +} from './generate-client-telemetry.js'; import type { ExitCode } from './miscellaneous.js'; import { respondWithinMs } from './network-check.js'; import { version } from './package.js'; @@ -48,6 +51,7 @@ export async function sendTelemetry({ lint_rules_with_warnings, lint_rules_with_ignored_problems, generate_client, + eject_generator, }: { config: Config | undefined; argv: Arguments | undefined; @@ -63,6 +67,7 @@ export async function sendTelemetry({ lint_rules_with_warnings: string[] | undefined; lint_rules_with_ignored_problems: string[] | undefined; generate_client?: GenerateClientTelemetry; + eject_generator?: EjectGeneratorTelemetry; }): Promise { try { if (!argv) { @@ -143,6 +148,16 @@ export async function sendTelemetry({ ? JSON.stringify(generate_client.generate_client_toolkit_imports) : undefined, generate_client_error_category: generate_client?.generate_client_error_category, + generate_client_ejected_generators: generate_client?.generate_client_ejected_generators + ?.length + ? JSON.stringify(generate_client.generate_client_ejected_generators) + : undefined, + // eject-generator / scaffold-generator usage (action, allowlisted name, coarse + // outcome — never user paths or user-chosen names). + eject_generator_action: eject_generator?.eject_generator_action, + eject_generator_name: eject_generator?.eject_generator_name, + eject_generator_outcome: eject_generator?.eject_generator_outcome, + eject_generator_conflicts: eject_generator?.eject_generator_conflicts, }, ]; diff --git a/packages/cli/src/wrapper.ts b/packages/cli/src/wrapper.ts index c70fa4e2c1..2ddabc0704 100644 --- a/packages/cli/src/wrapper.ts +++ b/packages/cli/src/wrapper.ts @@ -16,7 +16,10 @@ import type { Arguments } from 'yargs'; import type { CommandArgv } from './types.js'; import { AbortFlowError, exitWithError } from './utils/error.js'; -import { generateClientTelemetry } from './utils/generate-client-telemetry.js'; +import { + ejectGeneratorTelemetry, + generateClientTelemetry, +} from './utils/generate-client-telemetry.js'; import { loadConfigAndHandleErrors, type ExitCode } from './utils/miscellaneous.js'; import { version } from './utils/package.js'; import { @@ -148,6 +151,7 @@ export function commandWrapper( lint_rules_with_warnings: [...lintRulesWithWarnings], lint_rules_with_ignored_problems: [...lintRulesWithIgnoredProblems], generate_client: generateClientTelemetry, + eject_generator: ejectGeneratorTelemetry, }); } process.once('beforeExit', () => { diff --git a/packages/client-generator/src/pipeline.ts b/packages/client-generator/src/pipeline.ts index 1077c6070f..be08b6b72e 100644 --- a/packages/client-generator/src/pipeline.ts +++ b/packages/client-generator/src/pipeline.ts @@ -45,13 +45,20 @@ export function runGenerators( const seen = new Set(); for (const name of options.generators) { const generator = options.registry.get(name)!; - for (const file of generator.run({ - model, - outputPath: options.outputPath, - outputMode: options.outputMode, - emit: options.emit, - selected: options.generators, - })) { + let generated: GeneratedFile[]; + try { + generated = generator.run({ + model, + outputPath: options.outputPath, + outputMode: options.outputMode, + emit: options.emit, + selected: options.generators, + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Generator "${name}" failed: ${message}`); + } + for (const file of generated) { if (seen.has(file.path)) { throw new Error(`Generator conflict: ${file.path} already emitted by an earlier generator`); } From 29f48dce58ea18844fb111b41ed8a0312c7596dc Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 11:59:32 +0300 Subject: [PATCH 044/211] docs(client-generator): ejected-generator and scaffolded-generator examples --- tests/e2e/generate-client/examples/README.md | 2 + .../examples/ejected-generator/.gitignore | 3 + .../examples/ejected-generator/README.md | 13 + .../ejected-generator/generators/php.mjs | 578 ++++++++++++++++++ .../examples/ejected-generator/package.json | 13 + .../examples/ejected-generator/redocly.yaml | 9 + .../examples/scaffolded-generator/.gitignore | 3 + .../examples/scaffolded-generator/README.md | 11 + .../generators/ops-summary.mjs | 23 + .../scaffolded-generator/package.json | 13 + .../scaffolded-generator/redocly.yaml | 9 + 11 files changed, 677 insertions(+) create mode 100644 tests/e2e/generate-client/examples/ejected-generator/.gitignore create mode 100644 tests/e2e/generate-client/examples/ejected-generator/README.md create mode 100644 tests/e2e/generate-client/examples/ejected-generator/generators/php.mjs create mode 100644 tests/e2e/generate-client/examples/ejected-generator/package.json create mode 100644 tests/e2e/generate-client/examples/ejected-generator/redocly.yaml create mode 100644 tests/e2e/generate-client/examples/scaffolded-generator/.gitignore create mode 100644 tests/e2e/generate-client/examples/scaffolded-generator/README.md create mode 100644 tests/e2e/generate-client/examples/scaffolded-generator/generators/ops-summary.mjs create mode 100644 tests/e2e/generate-client/examples/scaffolded-generator/package.json create mode 100644 tests/e2e/generate-client/examples/scaffolded-generator/redocly.yaml diff --git a/tests/e2e/generate-client/examples/README.md b/tests/e2e/generate-client/examples/README.md index 45f6cb6f19..781006a298 100644 --- a/tests/e2e/generate-client/examples/README.md +++ b/tests/e2e/generate-client/examples/README.md @@ -30,6 +30,8 @@ The generated client under `src/api/` is gitignored — CI regenerates every cli | [python-sdk](./python-sdk) | CLI · `python` | a full Python SDK (httpx): typed dataclasses, sync/async clients, pagination iterators | | [go-sdk](./go-sdk) | CLI · `go` | a full Go SDK (stdlib-only): typed structs, `(T, error)` methods, `context.Context` | | [php-sdk](./php-sdk) | CLI · `php` | a full PHP SDK (curl extension): promoted-constructor classes, native enums, named-argument methods | +| [ejected-generator](./ejected-generator) | CLI · ejected `php` | `eject-generator php` vendored + customized: the path entry shadows the built-in name; regeneration keeps the customization | +| [scaffolded-generator](./scaffolded-generator) | CLI · `sdk` + scaffolded | `scaffold-generator` skeleton filled in — a markdown operations summary emitted next to the client | ## Run one diff --git a/tests/e2e/generate-client/examples/ejected-generator/.gitignore b/tests/e2e/generate-client/examples/ejected-generator/.gitignore new file mode 100644 index 0000000000..612acc5cae --- /dev/null +++ b/tests/e2e/generate-client/examples/ejected-generator/.gitignore @@ -0,0 +1,3 @@ +node_modules +src/api/ +package-lock.json diff --git a/tests/e2e/generate-client/examples/ejected-generator/README.md b/tests/e2e/generate-client/examples/ejected-generator/README.md new file mode 100644 index 0000000000..7ef4068bdb --- /dev/null +++ b/tests/e2e/generate-client/examples/ejected-generator/README.md @@ -0,0 +1,13 @@ +# ejected-generator + +The shadcn story for generators: `redocly eject-generator php` vendored the built-in PHP generator into `generators/php.mjs`, and this repo customized it — search the file for `CUSTOMIZATION` to see the one-line change (a platform banner in the generated header). +The _generated_ client stays machine-owned: regenerate any time and the customization is still there, because the customization lives in the generator, not in its output. + +```sh +npm run generate +head src/api/client.php # the customized banner is in the generated header +``` + +In your own repo, ejecting also writes `generators/.pristine/php.mjs` (commit it) and `generators/AGENTS.md` — the authoring guide your coding agent reads before editing the generator. +When a newer generator version ships, `redocly eject-generator php --update` three-way-merges it into your customized copy (pristine × new × yours); clean hunks apply silently, real conflicts get standard markers. +The ejected file imports the authoring toolkit and the embedded runtime from `@redocly/client-generator`, so runtime fixes still arrive with plain `npm update` — no merge needed. diff --git a/tests/e2e/generate-client/examples/ejected-generator/generators/php.mjs b/tests/e2e/generate-client/examples/ejected-generator/generators/php.mjs new file mode 100644 index 0000000000..37ba9b8d8b --- /dev/null +++ b/tests/e2e/generate-client/examples/ejected-generator/generators/php.mjs @@ -0,0 +1,578 @@ +// Ejected from @redocly/client-generator@0.2.0 — the built-in "php" generator. +// This file is yours: edit freely; the generated client stays machine-owned and is +// rebuilt by `redocly generate-client`. Newer generator versions merge in with +// `redocly eject-generator php --update`. +// The built-in `php` generator — the third non-TypeScript library entry, authored +// with the language-neutral toolkit only (same dogfooding invariant as python/go, +// pinned by the guard test). Output is a single PHP >= 8.1 file over the curl +// extension: promoted-constructor classes with fromArray/toArray hydration, native +// backed enums, match-based discriminator dispatchers, and a Client over the +// embedded runtime. Exceptions are the error mode (`errorMode` does not apply). +import { CodeWriter, 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 CodeWriter(' '); + 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 CodeWriter(' '); + const namespace = identifierFor(model.title, { style: 'pascal', reserved: PHP }); + writer.line('= 8.1, curl extension — zero Composer dependencies.'); + // CUSTOMIZATION: our platform banner — regeneration keeps it, `--update` merges around it. + writer.line('// Maintained by the Cafe platform team; see generators/php.mjs.'); + writer.blank(); + writer.line('declare(strict_types=1);'); + writer.blank(); + writer.line(`namespace ${namespace};`); + writer.blank(); + writer.line(renderPhpModels(model)); + writer.line('// ─── Embedded runtime (@redocly/client-generator php runtime) ───'); + writer.line(stripPhpHeader(PHP_RUNTIME_SOURCE)); + writer.blank(); + const operations = model.services.flatMap((service) => service.operations); + const paginationRules = new Map(); + for (const op of operations) { + const rule = paginationRuleFor(op, emit.pagination); + if (rule !== undefined) + paginationRules.set(op.name, rule); + } + writer.block('const OPERATIONS = [', () => { + for (const op of operations) { + const id = op.specName ?? op.name; + const security = phpSecurityLiteral(op, model); + const rule = paginationRules.get(op.name); + const fields = [ + `'id' => ${phpString(id)}`, + `'method' => ${phpString(op.method.toUpperCase())}`, + `'path' => ${phpString(op.path)}`, + ...(security !== undefined ? [`'security' => ${security}`] : []), + ...(rule !== undefined ? [`'pagination' => ${phpPaginationLiteral(rule)}`] : []), + ]; + writer.line(`${phpString(id)} => [${fields.join(', ')}],`); + } + }, '];'); + writer.blank(); + writeDocComment(writer, 'Client', `Client for ${model.title} (${model.version}).`); + writer.block('final class Client', () => { }, ''); + writer.block('{', () => { + writer.block('public function __construct(private Config $config)', () => { }, ''); + writer.block('{', () => { + writer.block("if ($this->config->serverUrl === '') {", () => { + writer.line(`$this->config->serverUrl = ${phpString(model.serverUrl ?? '')};`); + }, '}'); + }, '}'); + writer.blank(); + for (const op of operations) { + writePhpMethod(writer, op, model); + const rule = paginationRules.get(op.name); + if (rule === undefined) + continue; + const success = successSchema(op); + const pageHydration = success === undefined ? undefined : hydration(success, '$page', model); + // Resolve the items ARRAY, then take its raw element, so a `ref` element + // keeps its class name (a deref'd result would hydrate as plain data). + const itemsArray = success !== undefined && rule.items !== undefined + ? schemaAtPointer(success, rule.items, model) + : undefined; + const element = itemsArray?.kind === 'array' ? itemsArray.items : undefined; + const itemHydration = element === undefined ? undefined : hydration(element, '$item', model); + writePhpPaginationWrappers(writer, op, model, pageHydration, itemHydration, rule.items); + } + }, '}'); + return [{ path: outputPath.replace(/\.[^.\\/]+$/, '.php'), content: writer.toString() }]; +}; +/** One idiomatic PHP call per operation — feeds `x-codeSamples` for docs. */ +export function phpSample(op, ctx) { + const args = [ + ...op.pathParams.map((param) => `${phpString(`<${propertyName(param.name)}>`)}`), + ...(op.requestBody ? ['$body'] : []), + ...(op.queryParams.length > 0 + ? [`${propertyName(op.queryParams[0].name)}: ${phpString('')}`] + : []), + ]; + const namespace = identifierFor(ctx.model.title, { style: 'pascal', reserved: PHP }); + return { + lang: 'php', + label: 'PHP SDK', + source: `use ${namespace}\\{Client, Config};\n\n$client = new Client(new Config());\n$result = $client->${methodName(op)}(${args.join(', ')});\n`, + }; +} + +export default { + name: 'php', + run: phpGenerator, + sample: phpSample, +}; diff --git a/tests/e2e/generate-client/examples/ejected-generator/package.json b/tests/e2e/generate-client/examples/ejected-generator/package.json new file mode 100644 index 0000000000..1581a5d363 --- /dev/null +++ b/tests/e2e/generate-client/examples/ejected-generator/package.json @@ -0,0 +1,13 @@ +{ + "name": "@redocly-examples/ejected-generator", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "generate": "redocly generate-client" + }, + "devDependencies": { + "@redocly/cli": "latest", + "@redocly/client-generator": "latest" + } +} diff --git a/tests/e2e/generate-client/examples/ejected-generator/redocly.yaml b/tests/e2e/generate-client/examples/ejected-generator/redocly.yaml new file mode 100644 index 0000000000..7b950ab8b4 --- /dev/null +++ b/tests/e2e/generate-client/examples/ejected-generator/redocly.yaml @@ -0,0 +1,9 @@ +# redocly.yaml — drives `redocly generate-client` for this example. +# The path entry takes over the built-in `php` name (the ejected generator shadows its origin). +apis: + ejected-generator: + root: ../_shared/cafe.yaml + clientOutput: ./src/api/client.ts + client: + generators: + - ./generators/php.mjs diff --git a/tests/e2e/generate-client/examples/scaffolded-generator/.gitignore b/tests/e2e/generate-client/examples/scaffolded-generator/.gitignore new file mode 100644 index 0000000000..612acc5cae --- /dev/null +++ b/tests/e2e/generate-client/examples/scaffolded-generator/.gitignore @@ -0,0 +1,3 @@ +node_modules +src/api/ +package-lock.json diff --git a/tests/e2e/generate-client/examples/scaffolded-generator/README.md b/tests/e2e/generate-client/examples/scaffolded-generator/README.md new file mode 100644 index 0000000000..ad51b0ac8e --- /dev/null +++ b/tests/e2e/generate-client/examples/scaffolded-generator/README.md @@ -0,0 +1,11 @@ +# scaffolded-generator + +`redocly scaffold-generator ops-summary` created the skeleton for `generators/ops-summary.mjs` (plus `AGENTS.md`, the authoring guide for your coding agent); this example filled the skeleton in to emit a markdown operations summary next to the client. +The generator reads the same API model the built-ins consume, so the summary regenerates with the spec and can never drift from it. + +```sh +npm run generate +cat src/api/client.operations.md +``` + +To customize a built-in language generator instead of writing one from scratch, see the [`ejected-generator`](../ejected-generator) example. diff --git a/tests/e2e/generate-client/examples/scaffolded-generator/generators/ops-summary.mjs b/tests/e2e/generate-client/examples/scaffolded-generator/generators/ops-summary.mjs new file mode 100644 index 0000000000..f65b16377b --- /dev/null +++ b/tests/e2e/generate-client/examples/scaffolded-generator/generators/ops-summary.mjs @@ -0,0 +1,23 @@ +// Scaffolded with `redocly scaffold-generator ops-summary`, then filled in: +// emits a markdown operations summary next to the client — an artifact no +// built-in generator covers, derived from the same API model, so it can +// never drift from the description. +import { CodeWriter } from '@redocly/client-generator'; + +export default { + name: 'ops-summary', + run({ model, outputPath }) { + const writer = new CodeWriter(' '); + writer.line(`# ${model.title} ${model.version} — operations`); + writer.blank(); + writer.line('| Operation | Method | Path | Summary |'); + writer.line('| --- | --- | --- | --- |'); + for (const service of model.services) { + for (const op of service.operations) { + const summary = (op.summary ?? '').split('\n')[0]; + writer.line(`| ${op.name} | ${op.method.toUpperCase()} | \`${op.path}\` | ${summary} |`); + } + } + return [{ path: outputPath.replace(/\.[^.]+$/, '.operations.md'), content: writer.toString() }]; + }, +}; diff --git a/tests/e2e/generate-client/examples/scaffolded-generator/package.json b/tests/e2e/generate-client/examples/scaffolded-generator/package.json new file mode 100644 index 0000000000..27cfefda42 --- /dev/null +++ b/tests/e2e/generate-client/examples/scaffolded-generator/package.json @@ -0,0 +1,13 @@ +{ + "name": "@redocly-examples/scaffolded-generator", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "generate": "redocly generate-client" + }, + "devDependencies": { + "@redocly/cli": "latest", + "@redocly/client-generator": "latest" + } +} diff --git a/tests/e2e/generate-client/examples/scaffolded-generator/redocly.yaml b/tests/e2e/generate-client/examples/scaffolded-generator/redocly.yaml new file mode 100644 index 0000000000..b223fa605a --- /dev/null +++ b/tests/e2e/generate-client/examples/scaffolded-generator/redocly.yaml @@ -0,0 +1,9 @@ +# redocly.yaml — drives `redocly generate-client` for this example. +apis: + scaffolded-generator: + root: ../_shared/cafe.yaml + clientOutput: ./src/api/client.ts + client: + generators: + - sdk + - ./generators/ops-summary.mjs From 8c296a41ec75d66eb736a317bfd495885a7f5739 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 12:07:40 +0300 Subject: [PATCH 045/211] docs(client-generator): commit the AGENTS.md drop in the eject/scaffold examples, single marker ownership --- .../client-generator/eject-assets/AGENTS.md | 4 - tests/e2e/generate-client/examples.test.ts | 20 +++++ .../examples/ejected-generator/README.md | 3 +- .../ejected-generator/generators/AGENTS.md | 80 +++++++++++++++++++ .../examples/scaffolded-generator/README.md | 2 +- .../scaffolded-generator/generators/AGENTS.md | 80 +++++++++++++++++++ 6 files changed, 183 insertions(+), 6 deletions(-) create mode 100644 tests/e2e/generate-client/examples/ejected-generator/generators/AGENTS.md create mode 100644 tests/e2e/generate-client/examples/scaffolded-generator/generators/AGENTS.md diff --git a/packages/client-generator/eject-assets/AGENTS.md b/packages/client-generator/eject-assets/AGENTS.md index ab11451193..abe2e6a1b4 100644 --- a/packages/client-generator/eject-assets/AGENTS.md +++ b/packages/client-generator/eject-assets/AGENTS.md @@ -1,5 +1,3 @@ - - # Writing custom client generators A generator is a plain module: `(input) => GeneratedFile[]`. It receives the @@ -76,5 +74,3 @@ TypeScript-emitting generators may additionally use the TS toolkit from If you had to work around a **missing helper** or a wrong default, tell the user and suggest filing an issue at https://github.com/Redocly/redocly-cli/issues — include the generator snippet and the helper you expected to exist. - - diff --git a/tests/e2e/generate-client/examples.test.ts b/tests/e2e/generate-client/examples.test.ts index 62aaca272e..feb0e27d1e 100644 --- a/tests/e2e/generate-client/examples.test.ts +++ b/tests/e2e/generate-client/examples.test.ts @@ -83,3 +83,23 @@ describe('examples generate with the current generator', () => { }, 60_000); } }); + +describe('generator-authoring examples carry the current AGENTS.md', () => { + // The eject/scaffold examples commit the AGENTS.md drop so browsers see the full + // story; this pins them byte-for-byte to the shipped template (markers included). + const template = readFileSync( + join(repoRoot, 'packages/client-generator/eject-assets/AGENTS.md'), + 'utf-8' + ).trim(); + const expected = `\n\n${template}\n\n\n`; + + for (const example of ['ejected-generator', 'scaffolded-generator']) { + it(`${example}/generators/AGENTS.md matches the shipped template`, () => { + const dropped = readFileSync(join(examplesDir, example, 'generators/AGENTS.md'), 'utf-8'); + expect( + dropped, + `stale — re-run \`redocly eject-generator\` or \`scaffold-generator\` in the example` + ).toBe(expected); + }); + } +}); diff --git a/tests/e2e/generate-client/examples/ejected-generator/README.md b/tests/e2e/generate-client/examples/ejected-generator/README.md index 7ef4068bdb..589b46e446 100644 --- a/tests/e2e/generate-client/examples/ejected-generator/README.md +++ b/tests/e2e/generate-client/examples/ejected-generator/README.md @@ -8,6 +8,7 @@ npm run generate head src/api/client.php # the customized banner is in the generated header ``` -In your own repo, ejecting also writes `generators/.pristine/php.mjs` (commit it) and `generators/AGENTS.md` — the authoring guide your coding agent reads before editing the generator. +`generators/AGENTS.md` (committed here, exactly as the command drops it) is the authoring guide your coding agent reads before editing the generator — point your agent at it and describe the change you want. +In your own repo, ejecting also writes `generators/.pristine/php.mjs`; commit it — it is the merge base for updates. When a newer generator version ships, `redocly eject-generator php --update` three-way-merges it into your customized copy (pristine × new × yours); clean hunks apply silently, real conflicts get standard markers. The ejected file imports the authoring toolkit and the embedded runtime from `@redocly/client-generator`, so runtime fixes still arrive with plain `npm update` — no merge needed. diff --git a/tests/e2e/generate-client/examples/ejected-generator/generators/AGENTS.md b/tests/e2e/generate-client/examples/ejected-generator/generators/AGENTS.md new file mode 100644 index 0000000000..88ec0aeb50 --- /dev/null +++ b/tests/e2e/generate-client/examples/ejected-generator/generators/AGENTS.md @@ -0,0 +1,80 @@ + + +# Writing custom client generators + +A generator is a plain module: `(input) => GeneratedFile[]`. It receives the +language-agnostic API model and returns files — in ANY output language. It runs +in the same pass as the built-ins; select it by path in `redocly.yaml`: + +```yaml +client: + generators: [sdk, ./generators/my-generator.mjs] +``` + +## The contract + +```js +/** @type {import('@redocly/client-generator').CustomGenerator} */ +export default { + name: 'my-generator', + run({ model, outputPath, outputMode, emit }) { + return [{ path: outputPath.replace(/\.ts$/, '.mine.txt'), content: '…' }]; + }, + // Optional: one idiomatic call snippet per operation for docs (x-codeSamples), + // collected into an overlay file when `client.codeSamples: true` is set. + sample(operation, { model, emit }) { + return { lang: 'python', source: '…' }; + }, +}; +``` + +Rules: output is deterministic (same description → same bytes); never add +dependencies to the generated client; **never hand-edit generated output** — +edit this generator and regenerate. + +## The model (IR) + +`model.services[].operations[]` — each operation carries `name`, `specName`, +`method`, `path`, `tags`, `pathParams`/`queryParams`/`headerParams`/`cookieParams`, +`requestBody`, `successResponses`/`errorResponses` (each with a `schema`), and +`security`. `model.schemas` holds the named schemas. Every schema is a +discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, +`literal`, `enum`, `union` (optionally with a discriminator), `intersection` +(allOf), `null`, `unknown`, `omit`. + +## Helpers (import from '@redocly/client-generator') + +| Helper | Use | +| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `flattenAllOf(schema, model)` | The merged property view of allOf compositions — languages without intersection types render this. | +| `discriminatorCases(schema, model)` | `{ property, cases }` dispatch table for discriminated unions. | +| `isNullable(schema)` / `unwrapNullable(schema)` | Detect and strip `null` union members (`Optional[T]`, pointers, `Option`). | +| `enumValues(schema)` | Values plus SCREAMING_SNAKE member-name suggestions. | +| `casing` / `identifierFor(name, { style, reserved })` | camel/pascal/snake/screaming; keyword-safe identifiers (`RESERVED_WORDS.python/go/typescript` shipped). | +| `CodeWriter` | 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. | +| `AUTHORING_HELPER_NAMES` | The list of the above (introspection). | + +Worked example: the built-in `python` generator +(`packages/client-generator/src/generators/python.ts` in the Redocly CLI repo) is +authored with exactly this toolkit and nothing else — models via `flattenAllOf`/ +`enumValues`/`discriminatorCases`, all code through `CodeWriter`, every name through +`identifierFor(..., RESERVED_WORDS.python)`. + +TypeScript-emitting generators may additionally use the TS toolkit from +`@redocly/client-generator/generate` (`ts`, `printStatements`, `schemaToTypeNode`, …). + +## The loop + +1. Edit the generator. +2. Run `redocly generate-client`. +3. Inspect `git diff` of the generated output. +4. Repeat. Generated files are never hand-edited. + +If you had to work around a **missing helper** or a wrong default, tell the user +and suggest filing an issue at https://github.com/Redocly/redocly-cli/issues — +include the generator snippet and the helper you expected to exist. + + diff --git a/tests/e2e/generate-client/examples/scaffolded-generator/README.md b/tests/e2e/generate-client/examples/scaffolded-generator/README.md index ad51b0ac8e..e384d03c9a 100644 --- a/tests/e2e/generate-client/examples/scaffolded-generator/README.md +++ b/tests/e2e/generate-client/examples/scaffolded-generator/README.md @@ -1,6 +1,6 @@ # scaffolded-generator -`redocly scaffold-generator ops-summary` created the skeleton for `generators/ops-summary.mjs` (plus `AGENTS.md`, the authoring guide for your coding agent); this example filled the skeleton in to emit a markdown operations summary next to the client. +`redocly scaffold-generator ops-summary` created the skeleton for `generators/ops-summary.mjs` plus `generators/AGENTS.md` (committed here) — the authoring guide your coding agent uses as context to fill the skeleton in; this example evolved it into a markdown operations summary emitted next to the client. The generator reads the same API model the built-ins consume, so the summary regenerates with the spec and can never drift from it. ```sh diff --git a/tests/e2e/generate-client/examples/scaffolded-generator/generators/AGENTS.md b/tests/e2e/generate-client/examples/scaffolded-generator/generators/AGENTS.md new file mode 100644 index 0000000000..88ec0aeb50 --- /dev/null +++ b/tests/e2e/generate-client/examples/scaffolded-generator/generators/AGENTS.md @@ -0,0 +1,80 @@ + + +# Writing custom client generators + +A generator is a plain module: `(input) => GeneratedFile[]`. It receives the +language-agnostic API model and returns files — in ANY output language. It runs +in the same pass as the built-ins; select it by path in `redocly.yaml`: + +```yaml +client: + generators: [sdk, ./generators/my-generator.mjs] +``` + +## The contract + +```js +/** @type {import('@redocly/client-generator').CustomGenerator} */ +export default { + name: 'my-generator', + run({ model, outputPath, outputMode, emit }) { + return [{ path: outputPath.replace(/\.ts$/, '.mine.txt'), content: '…' }]; + }, + // Optional: one idiomatic call snippet per operation for docs (x-codeSamples), + // collected into an overlay file when `client.codeSamples: true` is set. + sample(operation, { model, emit }) { + return { lang: 'python', source: '…' }; + }, +}; +``` + +Rules: output is deterministic (same description → same bytes); never add +dependencies to the generated client; **never hand-edit generated output** — +edit this generator and regenerate. + +## The model (IR) + +`model.services[].operations[]` — each operation carries `name`, `specName`, +`method`, `path`, `tags`, `pathParams`/`queryParams`/`headerParams`/`cookieParams`, +`requestBody`, `successResponses`/`errorResponses` (each with a `schema`), and +`security`. `model.schemas` holds the named schemas. Every schema is a +discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, +`literal`, `enum`, `union` (optionally with a discriminator), `intersection` +(allOf), `null`, `unknown`, `omit`. + +## Helpers (import from '@redocly/client-generator') + +| Helper | Use | +| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `flattenAllOf(schema, model)` | The merged property view of allOf compositions — languages without intersection types render this. | +| `discriminatorCases(schema, model)` | `{ property, cases }` dispatch table for discriminated unions. | +| `isNullable(schema)` / `unwrapNullable(schema)` | Detect and strip `null` union members (`Optional[T]`, pointers, `Option`). | +| `enumValues(schema)` | Values plus SCREAMING_SNAKE member-name suggestions. | +| `casing` / `identifierFor(name, { style, reserved })` | camel/pascal/snake/screaming; keyword-safe identifiers (`RESERVED_WORDS.python/go/typescript` shipped). | +| `CodeWriter` | 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. | +| `AUTHORING_HELPER_NAMES` | The list of the above (introspection). | + +Worked example: the built-in `python` generator +(`packages/client-generator/src/generators/python.ts` in the Redocly CLI repo) is +authored with exactly this toolkit and nothing else — models via `flattenAllOf`/ +`enumValues`/`discriminatorCases`, all code through `CodeWriter`, every name through +`identifierFor(..., RESERVED_WORDS.python)`. + +TypeScript-emitting generators may additionally use the TS toolkit from +`@redocly/client-generator/generate` (`ts`, `printStatements`, `schemaToTypeNode`, …). + +## The loop + +1. Edit the generator. +2. Run `redocly generate-client`. +3. Inspect `git diff` of the generated output. +4. Repeat. Generated files are never hand-edited. + +If you had to work around a **missing helper** or a wrong default, tell the user +and suggest filing an issue at https://github.com/Redocly/redocly-cli/issues — +include the generator snippet and the helper you expected to exist. + + From 3b2f4cd8c3268caa7c5f00d581922d31cd4c68b6 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 12:12:10 +0300 Subject: [PATCH 046/211] docs(client-generator): bootstrap scripts and committed pristine in the eject/scaffold examples --- .../examples/ejected-generator/README.md | 7 +- .../generators/.pristine/php.mjs | 576 ++++++++++++++++++ .../examples/ejected-generator/package.json | 1 + .../examples/scaffolded-generator/.gitignore | 1 + .../examples/scaffolded-generator/README.md | 1 + .../scaffolded-generator/package.json | 1 + 6 files changed, 584 insertions(+), 3 deletions(-) create mode 100644 tests/e2e/generate-client/examples/ejected-generator/generators/.pristine/php.mjs diff --git a/tests/e2e/generate-client/examples/ejected-generator/README.md b/tests/e2e/generate-client/examples/ejected-generator/README.md index 589b46e446..9ea797aa12 100644 --- a/tests/e2e/generate-client/examples/ejected-generator/README.md +++ b/tests/e2e/generate-client/examples/ejected-generator/README.md @@ -5,10 +5,11 @@ The _generated_ client stays machine-owned: regenerate any time and the customiz ```sh npm run generate -head src/api/client.php # the customized banner is in the generated header +head src/api/client.php # the customized banner is in the generated header +npm run update-generator # merge a newer generator version into the customized copy ``` `generators/AGENTS.md` (committed here, exactly as the command drops it) is the authoring guide your coding agent reads before editing the generator — point your agent at it and describe the change you want. -In your own repo, ejecting also writes `generators/.pristine/php.mjs`; commit it — it is the merge base for updates. -When a newer generator version ships, `redocly eject-generator php --update` three-way-merges it into your customized copy (pristine × new × yours); clean hunks apply silently, real conflicts get standard markers. +`generators/.pristine/php.mjs` (committed, as it should be in your repo too) is the merge base: `npm run update-generator` three-way-merges a newer generator version into the customized copy — clean hunks apply silently, real conflicts get standard markers. +This example started from `redocly eject-generator php`; run that in your own repo to begin. The ejected file imports the authoring toolkit and the embedded runtime from `@redocly/client-generator`, so runtime fixes still arrive with plain `npm update` — no merge needed. diff --git a/tests/e2e/generate-client/examples/ejected-generator/generators/.pristine/php.mjs b/tests/e2e/generate-client/examples/ejected-generator/generators/.pristine/php.mjs new file mode 100644 index 0000000000..f9007853ba --- /dev/null +++ b/tests/e2e/generate-client/examples/ejected-generator/generators/.pristine/php.mjs @@ -0,0 +1,576 @@ +// 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 { CodeWriter, 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 CodeWriter(' '); + 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 CodeWriter(' '); + const namespace = identifierFor(model.title, { style: 'pascal', reserved: PHP }); + writer.line('= 8.1, curl extension — zero Composer dependencies.'); + writer.blank(); + writer.line('declare(strict_types=1);'); + writer.blank(); + writer.line(`namespace ${namespace};`); + writer.blank(); + writer.line(renderPhpModels(model)); + writer.line('// ─── Embedded runtime (@redocly/client-generator php runtime) ───'); + writer.line(stripPhpHeader(PHP_RUNTIME_SOURCE)); + writer.blank(); + const operations = model.services.flatMap((service) => service.operations); + const paginationRules = new Map(); + for (const op of operations) { + const rule = paginationRuleFor(op, emit.pagination); + if (rule !== undefined) + paginationRules.set(op.name, rule); + } + writer.block('const OPERATIONS = [', () => { + for (const op of operations) { + const id = op.specName ?? op.name; + const security = phpSecurityLiteral(op, model); + const rule = paginationRules.get(op.name); + const fields = [ + `'id' => ${phpString(id)}`, + `'method' => ${phpString(op.method.toUpperCase())}`, + `'path' => ${phpString(op.path)}`, + ...(security !== undefined ? [`'security' => ${security}`] : []), + ...(rule !== undefined ? [`'pagination' => ${phpPaginationLiteral(rule)}`] : []), + ]; + writer.line(`${phpString(id)} => [${fields.join(', ')}],`); + } + }, '];'); + writer.blank(); + writeDocComment(writer, 'Client', `Client for ${model.title} (${model.version}).`); + writer.block('final class Client', () => { }, ''); + writer.block('{', () => { + writer.block('public function __construct(private Config $config)', () => { }, ''); + writer.block('{', () => { + writer.block("if ($this->config->serverUrl === '') {", () => { + writer.line(`$this->config->serverUrl = ${phpString(model.serverUrl ?? '')};`); + }, '}'); + }, '}'); + writer.blank(); + for (const op of operations) { + writePhpMethod(writer, op, model); + const rule = paginationRules.get(op.name); + if (rule === undefined) + continue; + const success = successSchema(op); + const pageHydration = success === undefined ? undefined : hydration(success, '$page', model); + // Resolve the items ARRAY, then take its raw element, so a `ref` element + // keeps its class name (a deref'd result would hydrate as plain data). + const itemsArray = success !== undefined && rule.items !== undefined + ? schemaAtPointer(success, rule.items, model) + : undefined; + const element = itemsArray?.kind === 'array' ? itemsArray.items : undefined; + const itemHydration = element === undefined ? undefined : hydration(element, '$item', model); + writePhpPaginationWrappers(writer, op, model, pageHydration, itemHydration, rule.items); + } + }, '}'); + return [{ path: outputPath.replace(/\.[^.\\/]+$/, '.php'), content: writer.toString() }]; +}; +/** One idiomatic PHP call per operation — feeds `x-codeSamples` for docs. */ +export function phpSample(op, ctx) { + const args = [ + ...op.pathParams.map((param) => `${phpString(`<${propertyName(param.name)}>`)}`), + ...(op.requestBody ? ['$body'] : []), + ...(op.queryParams.length > 0 + ? [`${propertyName(op.queryParams[0].name)}: ${phpString('')}`] + : []), + ]; + const namespace = identifierFor(ctx.model.title, { style: 'pascal', reserved: PHP }); + return { + lang: 'php', + label: 'PHP SDK', + source: `use ${namespace}\\{Client, Config};\n\n$client = new Client(new Config());\n$result = $client->${methodName(op)}(${args.join(', ')});\n`, + }; +} + +export default { + name: 'php', + run: phpGenerator, + sample: phpSample, +}; diff --git a/tests/e2e/generate-client/examples/ejected-generator/package.json b/tests/e2e/generate-client/examples/ejected-generator/package.json index 1581a5d363..40b1097693 100644 --- a/tests/e2e/generate-client/examples/ejected-generator/package.json +++ b/tests/e2e/generate-client/examples/ejected-generator/package.json @@ -4,6 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { + "update-generator": "redocly eject-generator php --update", "generate": "redocly generate-client" }, "devDependencies": { diff --git a/tests/e2e/generate-client/examples/scaffolded-generator/.gitignore b/tests/e2e/generate-client/examples/scaffolded-generator/.gitignore index 612acc5cae..9c0c76b464 100644 --- a/tests/e2e/generate-client/examples/scaffolded-generator/.gitignore +++ b/tests/e2e/generate-client/examples/scaffolded-generator/.gitignore @@ -1,3 +1,4 @@ node_modules src/api/ package-lock.json +generators/my-generator.mjs diff --git a/tests/e2e/generate-client/examples/scaffolded-generator/README.md b/tests/e2e/generate-client/examples/scaffolded-generator/README.md index e384d03c9a..5528fda202 100644 --- a/tests/e2e/generate-client/examples/scaffolded-generator/README.md +++ b/tests/e2e/generate-client/examples/scaffolded-generator/README.md @@ -6,6 +6,7 @@ The generator reads the same API model the built-ins consume, so the summary reg ```sh npm run generate cat src/api/client.operations.md +npm run scaffold # try the command yourself: scaffolds a fresh generators/my-generator.mjs ``` To customize a built-in language generator instead of writing one from scratch, see the [`ejected-generator`](../ejected-generator) example. diff --git a/tests/e2e/generate-client/examples/scaffolded-generator/package.json b/tests/e2e/generate-client/examples/scaffolded-generator/package.json index 27cfefda42..ee6f1d97fd 100644 --- a/tests/e2e/generate-client/examples/scaffolded-generator/package.json +++ b/tests/e2e/generate-client/examples/scaffolded-generator/package.json @@ -4,6 +4,7 @@ "version": "0.0.0", "type": "module", "scripts": { + "scaffold": "redocly scaffold-generator my-generator", "generate": "redocly generate-client" }, "devDependencies": { From c61f052d69e24e5f2f7858207ad3f0575be05afe Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 12:51:12 +0300 Subject: [PATCH 047/211] feat(client-generator): text-template TS type renderer, printer-equivalent by construction --- .../src/emitters/__tests__/ts-type.test.ts | 146 +++++++++++++++ .../client-generator/src/emitters/jsdoc.ts | 6 +- .../client-generator/src/emitters/ts-type.ts | 167 ++++++++++++++++++ packages/client-generator/src/emitters/ts.ts | 6 +- 4 files changed, 320 insertions(+), 5 deletions(-) create mode 100644 packages/client-generator/src/emitters/__tests__/ts-type.test.ts create mode 100644 packages/client-generator/src/emitters/ts-type.ts diff --git a/packages/client-generator/src/emitters/__tests__/ts-type.test.ts b/packages/client-generator/src/emitters/__tests__/ts-type.test.ts new file mode 100644 index 0000000000..b8cf7c1156 --- /dev/null +++ b/packages/client-generator/src/emitters/__tests__/ts-type.test.ts @@ -0,0 +1,146 @@ +import type { NamedSchemaModel, SchemaModel } from '../../intermediate-representation/model.js'; +import { renderTypeAliases, tsType } from '../ts-type.js'; +import { printStatements } from '../ts.js'; +import { renderSchema, typesStatements, type DateType } from '../types.js'; + +// The text renderer replaces the AST printer; while both exist, equivalence is +// asserted against the printer's OWN output across the whole schema vocabulary — +// printer fidelity by construction, so downstream snapshots don't churn per-type. + +const STRING: SchemaModel = { kind: 'scalar', scalar: 'string' }; +const INT: SchemaModel = { kind: 'scalar', scalar: 'integer' }; +const BOOL: SchemaModel = { kind: 'scalar', scalar: 'boolean' }; + +const CASES: Array<[string, SchemaModel, DateType?]> = [ + ['string', STRING], + ['number', { kind: 'scalar', scalar: 'number' }], + ['integer', INT], + ['boolean', BOOL], + ['binary → Blob', { kind: 'scalar', scalar: 'string', metadata: { format: 'binary' } }], + ['date kept as string', { kind: 'scalar', scalar: 'string', metadata: { format: 'date-time' } }], + ['date as Date', { kind: 'scalar', scalar: 'string', metadata: { format: 'date-time' } }, 'Date'], + ['ref', { kind: 'ref', name: 'Order' }], + ['string literal', { kind: 'literal', value: 'fixed' }], + ['number literal', { kind: 'literal', value: 42 }], + ['boolean literal', { kind: 'literal', value: true }], + ['single-value enum', { kind: 'enum', values: ['only'], scalar: 'string' }], + ['string enum', { kind: 'enum', values: ['a', 'b'], scalar: 'string' }], + ['integer enum', { kind: 'enum', values: [1, 2], scalar: 'integer' }], + ['null', { kind: 'null' }], + ['unknown', { kind: 'unknown' }], + ['array of scalar', { kind: 'array', items: STRING }], + [ + 'array of union (parenthesized)', + { kind: 'array', items: { kind: 'union', members: [STRING, { kind: 'null' }] } }, + ], + [ + 'array of multi enum (parenthesized)', + { kind: 'array', items: { kind: 'enum', values: ['a', 'b'], scalar: 'string' } }, + ], + ['array of ref', { kind: 'array', items: { kind: 'ref', name: 'Order' } }], + ['record', { kind: 'record', value: { kind: 'union', members: [STRING, INT] } }], + ['empty object', { kind: 'object', properties: [] }], + [ + 'object with the full property vocabulary', + { + kind: 'object', + properties: [ + { name: 'id', schema: STRING, required: true, readOnly: true }, + { + name: 'note', + schema: STRING, + required: false, + description: 'Free-form note.\nSecond line.', + }, + { name: 'weird-name', schema: INT, required: true }, + { + name: 'limit', + schema: { kind: 'scalar', scalar: 'integer', metadata: { minimum: 1, maximum: 100 } }, + required: false, + }, + { + name: 'nested', + schema: { + kind: 'object', + properties: [{ name: 'deep', schema: BOOL, required: false }], + }, + required: true, + }, + ], + }, + ], + [ + 'union with object member', + { + kind: 'union', + members: [ + { kind: 'object', properties: [{ name: 'a', schema: STRING, required: true }] }, + { kind: 'null' }, + ], + }, + ], + [ + 'intersection with union member (parenthesized)', + { + kind: 'intersection', + members: [ + { kind: 'ref', name: 'Base' }, + { kind: 'union', members: [STRING, INT] }, + ], + }, + ], + ['omit', { kind: 'omit', base: 'Pet', keys: ['id', 'createdAt'] }], +]; + +describe('tsType matches the AST printer for every schema shape', () => { + it.each(CASES)('%s', (_label, schema, dateType) => { + expect(tsType(schema, dateType ?? 'string')).toBe(renderSchema(schema, dateType ?? 'string')); + }); +}); + +describe('renderTypeAliases matches printStatements(typesStatements(…))', () => { + it('aliases with JSDoc, enum const companions, and quoted-value enums', () => { + const schemas: NamedSchemaModel[] = [ + { + name: 'Status', + schema: { kind: 'enum', values: ['open', 'closed'], scalar: 'string' }, + }, + { + name: 'Scopes', + // `menu:read` is not a valid identifier — no const companion. + schema: { kind: 'enum', values: ['menu:read', 'menu:write'], scalar: 'string' }, + }, + { + name: 'Order', + schema: { + kind: 'object', + description: 'One placed order.', + metadata: { deprecated: true }, + properties: [{ name: 'id', schema: STRING, required: true }], + }, + }, + { + name: 'Page', + schema: { + kind: 'intersection', + members: [ + { kind: 'ref', name: 'Base' }, + { + kind: 'object', + properties: [ + { + name: 'items', + schema: { kind: 'array', items: { kind: 'ref', name: 'Order' } }, + required: true, + }, + ], + }, + ], + }, + }, + ]; + expect(renderTypeAliases(schemas, 'string')).toBe( + printStatements(typesStatements(schemas, 'string')) + ); + }); +}); diff --git a/packages/client-generator/src/emitters/jsdoc.ts b/packages/client-generator/src/emitters/jsdoc.ts index 1e824fc47e..c9a7f304b8 100644 --- a/packages/client-generator/src/emitters/jsdoc.ts +++ b/packages/client-generator/src/emitters/jsdoc.ts @@ -1,6 +1,10 @@ import type { SchemaMetadata } from '../intermediate-representation/model.js'; import { splitLines } from './support.js'; -import { escapeJsDoc } from './ts.js'; + +/** Backslash-escape any comment-closing star-slash so it cannot terminate a block comment. */ +export function escapeJsDoc(text: string): string { + return text.replace(/\*\//g, '*\\/'); +} /** * The JSDoc body for a description + metadata as a single `\n`-joined string, diff --git a/packages/client-generator/src/emitters/ts-type.ts b/packages/client-generator/src/emitters/ts-type.ts new file mode 100644 index 0000000000..b11fb95f1f --- /dev/null +++ b/packages/client-generator/src/emitters/ts-type.ts @@ -0,0 +1,167 @@ +// TypeScript TYPES as source text — the template-based replacement for the AST +// printer path (`schemaToTypeNode` + `printNodes`). Pure string logic over the +// IR: no `typescript` import, so the sdk generator joins the same TS-free +// authoring model as python/go/php. Formatting matches the printer (4-space +// indent, double quotes, union/intersection parenthesization) so the migration +// does not churn generated output shape. + +import type { + NamedSchemaModel, + PropertyModel, + ScalarKind, + SchemaMetadata, + SchemaModel, +} from '../intermediate-representation/model.js'; +import { isIdentifier, safeIdent } from './identifier.js'; +import { escapeJsDoc, jsdocText } from './jsdoc.js'; +import type { DateType } from './types.js'; + +const INDENT = ' '; + +/** A JSDoc block (description + metadata tags) as indented lines, or [] when empty. */ +export function tsJsdoc( + text: string | undefined, + metadata: SchemaMetadata | undefined, + indent: string +): string[] { + const body = jsdocText(text, metadata); + if (body === undefined) return []; + return [ + `${indent}/**`, + ...escapeJsDoc(body) + .split('\n') + .map((line) => `${indent} * ${line}`.replace(/ +$/, '')), + `${indent} */`, + ]; +} + +function literalType(value: string | number | boolean): string { + return typeof value === 'string' ? JSON.stringify(value) : String(value); +} + +function scalarType( + kind: ScalarKind, + metadata: SchemaMetadata | undefined, + dateType: DateType +): string { + switch (kind) { + case 'string': + if (metadata?.format === 'binary') return 'Blob'; + if ( + dateType === 'Date' && + (metadata?.format === 'date-time' || metadata?.format === 'date') + ) { + return 'Date'; + } + return 'string'; + case 'number': + case 'integer': + return 'number'; + case 'boolean': + return 'boolean'; + } +} + +/** True when the rendered type needs parentheses as an array element / intersection member. */ +function isCompound(schema: SchemaModel): boolean { + return ( + schema.kind === 'union' || + schema.kind === 'intersection' || + (schema.kind === 'enum' && schema.values.length > 1) + ); +} + +/** The TypeScript type for an IR schema, rendered at `indent` (the containing line's indent). */ +export function tsType(schema: SchemaModel, dateType: DateType = 'string', indent = ''): string { + switch (schema.kind) { + case 'scalar': + return scalarType(schema.scalar, schema.metadata, dateType); + case 'ref': + return schema.name; + case 'literal': + return literalType(schema.value); + case 'enum': + return schema.values.map(literalType).join(' | '); + case 'null': + return 'null'; + case 'unknown': + return 'unknown'; + case 'array': { + const element = tsType(schema.items, dateType, indent); + return isCompound(schema.items) ? `(${element})[]` : `${element}[]`; + } + case 'record': + return `Record`; + case 'object': { + if (schema.properties.length === 0) return '{}'; + const inner = indent + INDENT; + const lines = schema.properties.flatMap((property) => + propertyLines(property, dateType, inner) + ); + return `{\n${lines.join('\n')}\n${indent}}`; + } + case 'union': + return schema.members + .map((member) => { + const rendered = tsType(member, dateType, indent); + return member.kind === 'intersection' ? `(${rendered})` : rendered; + }) + .join(' | '); + case 'intersection': + return schema.members + .map((member) => { + const rendered = tsType(member, dateType, indent); + return isCompound(member) ? `(${rendered})` : rendered; + }) + .join(' & '); + case 'omit': + return `Omit<${schema.base}, ${schema.keys.map((key) => JSON.stringify(key)).join(' | ')}>`; + } +} + +function propertyLines(property: PropertyModel, dateType: DateType, indent: string): string[] { + const name = safeIdent(property.name); + const readonly = property.readOnly ? 'readonly ' : ''; + const optional = property.required ? '' : '?'; + const type = tsType(property.schema, dateType, indent); + return [ + ...tsJsdoc(property.description, property.schema.metadata, indent), + `${indent}${readonly}${name}${optional}: ${type};`, + ]; +} + +/** + * For a named **string** enum whose values are all valid identifiers, the runtime + * companion `export const X = { a: "a", … } as const;` (cohabiting with the type). + */ +function enumConstLines(named: NamedSchemaModel): string[] { + const schema = named.schema; + if (schema.kind !== 'enum' || schema.scalar !== 'string') return []; + if (!schema.values.every((value) => typeof value === 'string' && isIdentifier(value))) return []; + return [ + `export const ${named.name} = {`, + ...schema.values.map( + (value, index) => + `${INDENT}${value}: ${JSON.stringify(value)}${index === schema.values.length - 1 ? '' : ','}` + ), + '} as const;', + ]; +} + +/** The model type aliases (with JSDoc and enum const companions), blank-line separated. */ +export function renderTypeAliases( + schemas: NamedSchemaModel[], + dateType: DateType = 'string' +): string { + const blocks: string[] = []; + for (const named of schemas) { + const lines = [ + ...tsJsdoc(named.schema.description ?? named.description, named.schema.metadata, ''), + `export type ${named.name} = ${tsType(named.schema, dateType)};`, + ]; + blocks.push(lines.join('\n')); + const constCompanion = enumConstLines(named); + if (constCompanion.length > 0) blocks.push(constCompanion.join('\n')); + } + return blocks.join('\n\n'); +} diff --git a/packages/client-generator/src/emitters/ts.ts b/packages/client-generator/src/emitters/ts.ts index ce627e4811..eff7ea8c2d 100644 --- a/packages/client-generator/src/emitters/ts.ts +++ b/packages/client-generator/src/emitters/ts.ts @@ -7,6 +7,7 @@ import ts from 'typescript'; import { isIdentifier } from './identifier.js'; +import { escapeJsDoc } from './jsdoc.js'; // TypeScript 7 (the native compiler) ships only the tsc binary — none of the compiler // API everything below is built on — yet its package resolves fine, so the first @@ -91,10 +92,7 @@ export function jsdoc(node: T, text: string): T { return ts.addSyntheticLeadingComment(node, ts.SyntaxKind.MultiLineCommentTrivia, body, true); } -/** Backslash-escape any comment-closing star-slash so it cannot terminate a block comment. */ -export function escapeJsDoc(text: string): string { - return text.replace(/\*\//g, '*\\/'); -} +export { escapeJsDoc } from './jsdoc.js'; const { factory } = ts; From f4330f5d0f60f5092cd6998967a7c95d74cfcb2f Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 12:59:50 +0300 Subject: [PATCH 048/211] feat(client-generator): text-template data literals and descriptor block, printer-equivalent --- .../__tests__/render-descriptors.test.ts | 146 ++++++++++++++++++ .../src/emitters/__tests__/ts-literal.test.ts | 38 +++++ .../src/emitters/descriptor.ts | 41 +++++ .../src/emitters/ts-literal.ts | 21 +++ 4 files changed, 246 insertions(+) create mode 100644 packages/client-generator/src/emitters/__tests__/render-descriptors.test.ts create mode 100644 packages/client-generator/src/emitters/__tests__/ts-literal.test.ts create mode 100644 packages/client-generator/src/emitters/ts-literal.ts diff --git a/packages/client-generator/src/emitters/__tests__/render-descriptors.test.ts b/packages/client-generator/src/emitters/__tests__/render-descriptors.test.ts new file mode 100644 index 0000000000..49aa769963 --- /dev/null +++ b/packages/client-generator/src/emitters/__tests__/render-descriptors.test.ts @@ -0,0 +1,146 @@ +import type { ApiModel } from '../../intermediate-representation/model.js'; +import { descriptorStatements, packageIdents, renderDescriptors } from '../descriptor.js'; +import { resolveModelPagination } from '../pagination.js'; +import { printStatements } from '../ts.js'; + +// Equivalence against the AST printer across the descriptor vocabulary: param styles, +// every security kind, multipart bodies, SSE, pagination, tags, and a renamed ident. +const MODEL = { + title: 'Cafe', + version: '1.0.0', + services: [ + { + name: 'Default', + operations: [ + { + name: 'listOrders', + specName: 'listOrders', + method: 'get', + path: '/orders', + tags: ['Orders'], + pathParams: [], + queryParams: [ + { + name: 'after', + in: 'query', + required: false, + schema: { kind: 'scalar', scalar: 'string' }, + }, + { + name: 'filter', + in: 'query', + required: false, + style: 'deepObject', + explode: true, + schema: { kind: 'record', value: { kind: 'scalar', scalar: 'string' } }, + }, + ], + headerParams: [ + { + name: 'X-Trace', + in: 'header', + required: false, + allowReserved: true, + schema: { kind: 'scalar', scalar: 'string' }, + }, + ], + cookieParams: [], + security: [['Bearer'], ['HeaderKey', 'CookieKey']], + paginationExtension: { + style: 'cursor', + cursorParam: 'after', + nextCursor: '/next', + items: '/items', + }, + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { + kind: 'object', + properties: [ + { + name: 'items', + schema: { kind: 'array', items: { kind: 'scalar', scalar: 'string' } }, + required: true, + }, + { name: 'next', schema: { kind: 'scalar', scalar: 'string' }, required: false }, + ], + }, + }, + ], + errorResponses: [], + }, + { + // Collides with wiring — packageIdents renames it. + name: 'configure', + specName: 'configure', + method: 'post', + path: '/configure', + tags: [], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [['QueryKey']], + requestBody: { + contentType: 'multipart/form-data', + required: true, + schema: { + kind: 'object', + properties: [ + { + name: 'photo', + schema: { kind: 'scalar', scalar: 'string', metadata: { format: 'binary' } }, + required: true, + }, + ], + }, + }, + successResponses: [], + errorResponses: [], + }, + { + name: 'streamEvents', + specName: 'streamEvents', + method: 'get', + path: '/events', + tags: [], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { + status: '200', + contentType: 'text/event-stream', + schema: { kind: 'object', properties: [] }, + }, + ], + errorResponses: [], + }, + ], + }, + ], + schemas: [], + securitySchemes: [ + { key: 'Bearer', kind: 'bearer' }, + { key: 'HeaderKey', kind: 'apiKeyHeader', headerName: 'X-Key' }, + { key: 'QueryKey', kind: 'apiKeyQuery', paramName: 'api_key' }, + { key: 'CookieKey', kind: 'apiKeyCookie', cookieName: 'sid' }, + ], +} as unknown as ApiModel; + +describe('renderDescriptors matches printStatements(descriptorStatements(…))', () => { + it('full vocabulary, with and without pagination', () => { + const idents = packageIdents(MODEL); + const pagination = resolveModelPagination(MODEL, undefined); + expect(renderDescriptors(MODEL, idents, 'string', pagination)).toBe( + printStatements(descriptorStatements(MODEL, idents, 'string', pagination)) + ); + expect(renderDescriptors(MODEL, idents, 'string')).toBe( + printStatements(descriptorStatements(MODEL, idents, 'string')) + ); + }); +}); diff --git a/packages/client-generator/src/emitters/__tests__/ts-literal.test.ts b/packages/client-generator/src/emitters/__tests__/ts-literal.test.ts new file mode 100644 index 0000000000..e10ea971ac --- /dev/null +++ b/packages/client-generator/src/emitters/__tests__/ts-literal.test.ts @@ -0,0 +1,38 @@ +import { codeLiteral } from '../ts-literal.js'; +import { literalExpression, printNodes } from '../ts.js'; + +// Equivalence against the AST printer's own output — same bar as ts-type.test.ts. +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 matches the AST printer', () => { + it.each(CASES)('%s', (_label, value) => { + expect(codeLiteral(value)).toBe(printNodes([literalExpression(value)])); + }); +}); diff --git a/packages/client-generator/src/emitters/descriptor.ts b/packages/client-generator/src/emitters/descriptor.ts index b29e9ca2e2..7c021f79c7 100644 --- a/packages/client-generator/src/emitters/descriptor.ts +++ b/packages/client-generator/src/emitters/descriptor.ts @@ -22,6 +22,8 @@ import { WIRING_NAMES } from './reserved-names.js'; import { responseHeadersTypeLiteral, responseHeaderSpecs } from './response-headers.js'; import { isSseOp, sseDataKind, sseEventType } from './sse.js'; import { pascalCase } from './support.js'; +import { codeLiteral } from './ts-literal.js'; +import { tsJsdoc } from './ts-type.js'; import { jsdoc, literalExpression, parseStatements, ts } from './ts.js'; import { type DateType, schemaToTypeNode } from './types.js'; @@ -163,6 +165,45 @@ export function descriptorStatements( return [operations, ...derived]; } +/** Text twin of `descriptorStatements` — printer-equivalent (pinned by its test). */ +export function renderDescriptors( + model: ApiModel, + idents: Map, + dateType: DateType, + pagination?: ModelPagination +): string { + const ops = allOperations(model.services); + if (ops.length === 0) return ''; + const entryLines = ops.map((op, index) => { + const value = codeLiteral(descriptorValue(op, model.securitySchemes, dateType, pagination)); + return ` ${idents.get(op.name)!}: ${value}${index === ops.length - 1 ? '' : ','}`; + }); + const blocks = [ + [ + ...tsJsdoc( + 'The wire-shape descriptor for every operation, keyed by operationId — the data the\n' + + 'runtime routes requests by. Also minification-safe static metadata (method, path,\n' + + 'tags) for cache keys, tracing span names, and request logging.', + undefined, + '' + ), + 'export const OPERATIONS = {', + ...entryLines, + '} as const satisfies Record;', + ].join('\n'), + 'export type OperationId = (typeof OPERATIONS)[keyof typeof OPERATIONS]["id"];', + 'export type OperationPath = (typeof OPERATIONS)[keyof typeof OPERATIONS]["path"];', + ]; + if (ops.some((op) => op.tags.length > 0)) { + blocks.push( + 'export type OperationTag = Extract<(typeof OPERATIONS)[keyof typeof OPERATIONS], {\n' + + ' tags: readonly string[];\n' + + '}>["tags"][number];' + ); + } + return blocks.join('\n\n'); +} + /** * `export type Ops = { : { args: …; result: …; kind?: "sse" } }` — the type map * `createClient` consumes. A type alias (not an interface) on purpose: aliases get diff --git a/packages/client-generator/src/emitters/ts-literal.ts b/packages/client-generator/src/emitters/ts-literal.ts new file mode 100644 index 0000000000..6a53d89aac --- /dev/null +++ b/packages/client-generator/src/emitters/ts-literal.ts @@ -0,0 +1,21 @@ +// Plain data → TypeScript expression text: the template-based replacement for +// `literalExpression` + the printer. Single-line, printer-matching formatting +// (`{ a: 1, b: [2, 3] }`); keys stay bare when they pass the identifier GRAMMAR +// (reserved words are legal object-literal keys), quoted otherwise. + +import { isIdentifier } from './identifier.js'; + +/** A JSON-ish value as TypeScript source text. */ +export function codeLiteral(value: unknown): string { + if (typeof value === 'string') return JSON.stringify(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 : JSON.stringify(key)}: ${codeLiteral(entryValue)}` + ); + return entries.length === 0 ? '{}' : `{ ${entries.join(', ')} }`; +} From 81b3800b83b5cc6316a8a1fe68a8f2594cb4af7a Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 13:07:19 +0300 Subject: [PATCH 049/211] feat(client-generator): text-template Ops type and operation aliases, printer-equivalent --- .../emitters/__tests__/render-client.test.ts | 256 ++++++++++++++ .../src/emitters/render-client.ts | 313 ++++++++++++++++++ packages/client-generator/src/emitters/sse.ts | 2 +- 3 files changed, 570 insertions(+), 1 deletion(-) create mode 100644 packages/client-generator/src/emitters/__tests__/render-client.test.ts create mode 100644 packages/client-generator/src/emitters/render-client.ts diff --git a/packages/client-generator/src/emitters/__tests__/render-client.test.ts b/packages/client-generator/src/emitters/__tests__/render-client.test.ts new file mode 100644 index 0000000000..1da8415faa --- /dev/null +++ b/packages/client-generator/src/emitters/__tests__/render-client.test.ts @@ -0,0 +1,256 @@ +import type { ApiModel, OperationModel } from '../../intermediate-representation/model.js'; +import { opsInterfaceStatements, packageIdents } from '../descriptor.js'; +import { renderOperationAliases, sseAliases } from '../operation-aliases.js'; +import { operationSignature } from '../operation-signature.js'; +import { computeResponse, errorTypeNodes } from '../operation-types.js'; +import type { EmitContext } from '../operations.js'; +import { resolveModelPagination } from '../pagination.js'; +import { renderAliases, renderOpsType } from '../render-client.js'; +import { isSseOp } from '../sse.js'; +import { pascalCase } from '../support.js'; +import { printStatements } from '../ts.js'; + +// Printer-equivalence for the Ops type + `*` alias cluster — the deepest type +// surface of the sdk. The fixture exercises: path/query/header/cookie params with +// JSDoc, required and optional slots, multipart and urlencoded bodies, error +// responses (result mode), SSE with a typed payload, pagination (item/page members), +// alias suppression on schema collisions, and a renamed path-param binding. +const STRING = { kind: 'scalar', scalar: 'string' } as const; +const MODEL = { + title: 'Cafe', + version: '1.0.0', + services: [ + { + name: 'Default', + operations: [ + { + name: 'listOrders', + specName: 'listOrders', + method: 'get', + path: '/orders', + tags: [], + pathParams: [], + queryParams: [ + { + name: 'after', + in: 'query', + required: false, + description: 'Cursor of the page.', + schema: STRING, + }, + { + name: 'page-size', + in: 'query', + required: true, + schema: { kind: 'scalar', scalar: 'integer', metadata: { minimum: 1 } }, + }, + ], + headerParams: [{ name: 'X-Trace', in: 'header', required: false, schema: STRING }], + cookieParams: [{ name: 'session', in: 'cookie', required: true, schema: STRING }], + security: [], + paginationExtension: { + style: 'cursor', + cursorParam: 'after', + nextCursor: '/next', + items: '/items', + }, + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { + kind: 'object', + properties: [ + { + name: 'items', + schema: { kind: 'array', items: { kind: 'ref', name: 'Order' } }, + required: true, + }, + { name: 'next', schema: STRING, required: false }, + ], + }, + }, + ], + errorResponses: [ + { + status: '400', + contentType: 'application/json', + schema: { kind: 'ref', name: 'Problem' }, + }, + { + status: '500', + contentType: 'application/json', + schema: { kind: 'ref', name: 'Problem' }, + }, + ], + }, + { + // `params` as a path param forces the `_2` binding rename. + name: 'getOrder', + specName: 'getOrder', + method: 'get', + path: '/orders/{params}', + tags: [], + pathParams: [ + { + name: 'params', + in: 'path', + required: true, + description: 'Order id.', + schema: STRING, + }, + ], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'ref', name: 'Order' }, + }, + ], + errorResponses: [], + }, + { + // `SearchResult` schema exists — the `Result` alias is suppressed. + name: 'search', + specName: 'search', + method: 'post', + path: '/search', + tags: [], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + requestBody: { + contentType: 'application/x-www-form-urlencoded', + required: false, + schema: { kind: 'object', properties: [] }, + }, + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'ref', name: 'SearchResult' }, + }, + ], + errorResponses: [], + }, + { + name: 'uploadPhoto', + specName: 'uploadPhoto', + method: 'post', + path: '/photos', + tags: [], + pathParams: [], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + requestBody: { + contentType: 'multipart/form-data', + required: true, + schema: { + kind: 'object', + properties: [ + { + name: 'photo', + schema: { kind: 'scalar', scalar: 'string', metadata: { format: 'binary' } }, + required: true, + }, + { name: 'caption', schema: STRING, required: false }, + ], + }, + }, + successResponses: [], + errorResponses: [], + }, + { + name: 'streamEvents', + specName: 'streamEvents', + method: 'get', + path: '/events', + tags: [], + pathParams: [], + queryParams: [{ name: 'channel', in: 'query', required: false, schema: STRING }], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { + status: '200', + contentType: 'text/event-stream', + schema: { kind: 'ref', name: 'Order' }, + }, + ], + errorResponses: [], + }, + ], + }, + ], + schemas: [ + { + name: 'Order', + schema: { kind: 'object', properties: [{ name: 'id', schema: STRING, required: true }] }, + }, + { name: 'Problem', schema: { kind: 'object', properties: [] } }, + { name: 'SearchResult', schema: { kind: 'object', properties: [] } }, + ], + securitySchemes: [], +} as unknown as ApiModel; + +function makeCtx(errorMode: 'throw' | 'result'): EmitContext { + return { + argsStyle: 'flat', + errorMode, + dateType: 'string', + schemaNames: new Set(MODEL.schemas.map((s) => s.name)), + pagination: resolveModelPagination(MODEL, undefined), + }; +} + +/** The AST alias cluster exactly as client-assembly builds it (package mode). */ +function astAliases(op: OperationModel, ctx: EmitContext): string { + const { pathParams } = operationSignature(op); + const ordered = pathParams.map((p) => p.param); + const identMap = new Map(pathParams.map((p) => [p.param.name, p.ident])); + if (isSseOp(op)) return printStatements(sseAliases(op, ordered, identMap, ctx, 'wire')); + const { responseType } = computeResponse(op.successResponses, ctx.dateType); + const errorMembers = + ctx.errorMode === 'result' ? errorTypeNodes(op.errorResponses, ctx.dateType) : []; + const errorAlias = errorMembers.length > 0 ? `${pascalCase(op.name)}Error` : ''; + return printStatements( + renderOperationAliases( + op, + responseType, + ordered, + identMap, + errorAlias, + errorMembers, + ctx, + true, + 'wire' + ) + ); +} + +describe.each(['throw', 'result'] as const)('printer equivalence (%s mode)', (errorMode) => { + const ctx = makeCtx(errorMode); + const idents = packageIdents(MODEL); + + it('renderOpsType matches printStatements(opsInterfaceStatements(…))', () => { + expect(renderOpsType(MODEL, idents, ctx)).toBe( + printStatements(opsInterfaceStatements(MODEL, idents, ctx)) + ); + }); + + it.each(MODEL.services[0].operations.map((op) => [op.name, op] as const))( + 'renderAliases(%s) matches the AST alias cluster', + (_name, op) => { + expect(renderAliases(op, ctx, 'wire')).toBe(astAliases(op, ctx)); + } + ); +}); diff --git a/packages/client-generator/src/emitters/render-client.ts b/packages/client-generator/src/emitters/render-client.ts new file mode 100644 index 0000000000..edbf73097f --- /dev/null +++ b/packages/client-generator/src/emitters/render-client.ts @@ -0,0 +1,313 @@ +// The text-template client assembly — a DEEP module: its lasting public surface is +// the same two functions client-assembly.ts exposes today (single-file / split +// emission); everything below is internal plumbing that used to be spread across +// operation-types / operation-aliases / descriptor as AST builders. The part +// renderers are exported for the printer-equivalence tests only, and the exports +// shrink to the assembly seam when the flip lands. + +import { + allOperations, + type ApiModel, + type OperationModel, + type ParamModel, + type RequestBodyModel, + type ResponseBodyModel, +} from '../intermediate-representation/model.js'; +import { safeIdent } from './identifier.js'; +import { operationSignature } from './operation-signature.js'; +import { isTypedMultipart } from './operation-types.js'; +import type { EmitContext } from './operations.js'; +import { eventSchema, isSseOp } from './sse.js'; +import { pascalCase } from './support.js'; +import { tsJsdoc, tsType } from './ts-type.js'; +import type { DateType } from './types.js'; + +const INDENT = ' '; + +/** The request-body TS type: special wrapper types per content-type, else the schema. */ +export function bodyTypeText(rb: RequestBodyModel, dateType: DateType, indent = ''): string { + if (isTypedMultipart(rb)) return tsType(rb.schema, dateType, indent); + switch (rb.contentType) { + case 'multipart/form-data': + return 'FormData'; + case 'application/x-www-form-urlencoded': + return 'URLSearchParams'; + case 'application/octet-stream': + return 'Blob | ArrayBuffer'; + default: + return tsType(rb.schema, dateType, indent); + } +} + +/** The `{ … }` type literal for a params object (query or headers), with per-prop JSDoc. */ +export function paramsTypeText(params: ParamModel[], dateType: DateType, indent = ''): string { + const inner = indent + INDENT; + const lines = params.flatMap((param) => [ + ...tsJsdoc(param.description, param.schema.metadata, inner), + `${inner}${safeIdent(param.name)}${param.required ? '' : '?'}: ${tsType(param.schema, dateType, inner)};`, + ]); + return lines.length === 0 ? '{}' : `{\n${lines.join('\n')}\n${indent}}`; +} + +/** The success-response type + kind (JSON preferred; binary/text fall back; deduped union). */ +export function responseText( + responses: ResponseBodyModel[], + dateType: DateType, + indent = '' +): { type: string; kind: 'json' | 'blob' | 'text' | 'void' } { + if (responses.length === 0) return { type: 'void', kind: 'void' }; + const jsonResponse = responses.find((r) => r.contentType.toLowerCase().includes('json')); + if (jsonResponse) return { type: tsType(jsonResponse.schema, dateType, indent), kind: 'json' }; + const members: string[] = []; + const seen = new Set(); + let hasBinary = false; + let hasText = false; + for (const response of responses) { + let member: string; + if ( + response.contentType.startsWith('image/') || + response.contentType === 'application/octet-stream' + ) { + member = 'Blob'; + hasBinary = true; + } else if (response.contentType.startsWith('text/')) { + member = 'string'; + hasText = true; + } else { + member = tsType(response.schema, dateType, indent); + } + if (seen.has(member)) continue; + seen.add(member); + members.push(member); + } + return { type: members.join(' | '), kind: hasBinary ? 'blob' : hasText ? 'text' : 'json' }; +} + +/** The deduped error-response body types, or `[]` when none. */ +export function errorTypeTexts( + responses: ResponseBodyModel[], + dateType: DateType, + indent = '' +): string[] { + const seen = new Set(); + const members: string[] = []; + for (const response of responses) { + const member = tsType(response.schema, dateType, indent); + if (seen.has(member)) continue; + seen.add(member); + members.push(member); + } + return members; +} + +/** The TS type of a streamed event payload (`string` when no schema is declared). */ +function sseEventText(op: OperationModel, dateType: DateType, indent = ''): string { + const schema = eventSchema(op); + return schema ? tsType(schema, dateType, indent) : 'string'; +} + +/** A `(?): ` line, inlining the type when `` collides with a schema. */ +function inputPropLine( + key: string, + alias: string, + inlineType: () => string, + required: boolean, + schemaNames: Set, + indent: string +): string { + const type = schemaNames.has(alias) ? inlineType() : alias; + return `${indent}${key}${required ? '' : '?'}: ${type};`; +} + +/** The `Variables` object type literal (see operation-aliases.ts for the contract). */ +export function variablesTypeText( + op: OperationModel, + name: string, + orderedPathParams: ParamModel[], + pathParamIdent: Map, + ctx: EmitContext, + pathKeys: 'ident' | 'wire', + indent = '' +): string { + const { dateType, schemaNames } = ctx; + const inner = indent + INDENT; + const lines: string[] = []; + for (const param of orderedPathParams) { + const key = pathKeys === 'wire' ? safeIdent(param.name) : pathParamIdent.get(param.name)!; + lines.push( + ...tsJsdoc(param.description, param.schema.metadata, inner), + `${inner}${key}: ${tsType(param.schema, dateType, inner)};` + ); + } + if (op.queryParams.length > 0) { + lines.push( + inputPropLine( + 'params', + `${name}Params`, + () => paramsTypeText(op.queryParams, dateType, inner), + op.queryParams.some((p) => p.required), + schemaNames, + inner + ) + ); + } + if (op.requestBody) { + lines.push( + inputPropLine( + 'body', + `${name}Body`, + () => bodyTypeText(op.requestBody!, dateType, inner), + op.requestBody.required, + schemaNames, + inner + ) + ); + } + if (op.headerParams.length > 0) { + lines.push( + inputPropLine( + 'headers', + `${name}Headers`, + () => paramsTypeText(op.headerParams, dateType, inner), + op.headerParams.some((p) => p.required), + schemaNames, + inner + ) + ); + } + if (op.cookieParams.length > 0) { + lines.push( + inputPropLine( + 'cookies', + `${name}Cookies`, + () => paramsTypeText(op.cookieParams, dateType, inner), + op.cookieParams.some((p) => p.required), + schemaNames, + inner + ) + ); + } + return lines.length === 0 ? '{}' : `{\n${lines.join('\n')}\n${indent}}`; +} + +/** The raw success ref: the `Result` alias, or the inline type when that name collides. */ +function rawResultText(op: OperationModel, ctx: EmitContext, indent: string): string { + const resultName = `${pascalCase(op.name)}Result`; + return ctx.schemaNames.has(resultName) + ? responseText(op.successResponses, ctx.dateType, indent).type + : resultName; +} + +/** The `Result<…, E>` error argument (result mode): `unknown`, the alias, or the inline union. */ +function errorArgText(op: OperationModel, ctx: EmitContext, indent: string): string { + const members = errorTypeTexts(op.errorResponses, ctx.dateType, indent); + if (members.length === 0) return 'unknown'; + const alias = `${pascalCase(op.name)}Error`; + if (!ctx.schemaNames.has(alias)) return alias; + return members.join(' | '); +} + +/** The `Ops` type map — text twin of `opsInterfaceStatements` (printer-equivalence-pinned). */ +export function renderOpsType( + model: ApiModel, + idents: Map, + ctx: EmitContext +): string { + const ops = allOperations(model.services); + if (ops.length === 0) return ''; + const memberBlocks = ops.flatMap((op) => { + const ident = idents.get(op.name)!; + const name = pascalCase(op.name); + const { pathParams } = operationSignature(op); + const inner = INDENT + INDENT; + const args = variablesTypeText( + op, + name, + pathParams.map((p) => p.param), + new Map(pathParams.map((p) => [p.param.name, p.ident])), + ctx, + 'wire', + inner + ); + const sse = isSseOp(op); + const result = sse + ? sseEventText(op, ctx.dateType, inner) + : ctx.errorMode === 'result' + ? `Result<${rawResultText(op, ctx, inner)}, ${errorArgText(op, ctx, inner)}>` + : rawResultText(op, ctx, inner); + const lines = [`${inner}args: ${args};`, `${inner}result: ${result};`]; + const paginated = ctx.pagination?.get(op.name); + if (paginated) { + lines.push(`${inner}item: ${tsType(paginated.itemSchema, ctx.dateType, inner)};`); + if (ctx.errorMode === 'result') { + lines.push(`${inner}page: ${rawResultText(op, ctx, inner)};`); + } + } + if (sse) lines.push(`${inner}kind: "sse";`); + return [`${INDENT}${ident}: {`, ...lines, `${INDENT}};`]; + }); + return [ + ...tsJsdoc( + "Per-operation `args`/`result` shapes (plus `kind: 'sse'` for event streams) — the\n" + + 'type-level companion of `OPERATIONS` that gives `createClient` its typed methods.', + undefined, + '' + ), + 'export type Ops = {', + ...memberBlocks, + '};', + ].join('\n'); +} + +/** One operation's `*` aliases — text twin of the alias cluster (equivalence-pinned). */ +export function renderAliases( + op: OperationModel, + ctx: EmitContext, + pathKeys: 'ident' | 'wire' +): string { + const { dateType, schemaNames } = ctx; + const name = pascalCase(op.name); + const sse = isSseOp(op); + const { pathParams, hasInputs } = operationSignature(op); + const blocks: string[] = []; + + if (!sse) { + const resultName = `${name}Result`; + if (!schemaNames.has(resultName)) { + blocks.push( + `export type ${resultName} = ${responseText(op.successResponses, dateType).type};` + ); + } + if (ctx.errorMode === 'result') { + const members = errorTypeTexts(op.errorResponses, dateType); + const errorAlias = `${name}Error`; + if (members.length > 0 && !schemaNames.has(errorAlias)) { + blocks.push(`export type ${errorAlias} = ${members.join(' | ')};`); + } + } + } + if (op.queryParams.length > 0 && !schemaNames.has(`${name}Params`)) { + blocks.push(`export type ${name}Params = ${paramsTypeText(op.queryParams, dateType)};`); + } + if (op.requestBody && !schemaNames.has(`${name}Body`)) { + blocks.push(`export type ${name}Body = ${bodyTypeText(op.requestBody, dateType)};`); + } + if (op.headerParams.length > 0 && !schemaNames.has(`${name}Headers`)) { + blocks.push(`export type ${name}Headers = ${paramsTypeText(op.headerParams, dateType)};`); + } + if (op.cookieParams.length > 0 && !schemaNames.has(`${name}Cookies`)) { + blocks.push(`export type ${name}Cookies = ${paramsTypeText(op.cookieParams, dateType)};`); + } + if (hasInputs && !schemaNames.has(`${name}Variables`)) { + const variables = variablesTypeText( + op, + name, + pathParams.map((p) => p.param), + new Map(pathParams.map((p) => [p.param.name, p.ident])), + ctx, + pathKeys + ); + blocks.push(`export type ${name}Variables = ${variables};`); + } + return blocks.join('\n\n'); +} diff --git a/packages/client-generator/src/emitters/sse.ts b/packages/client-generator/src/emitters/sse.ts index b41d70159b..2169fb903a 100644 --- a/packages/client-generator/src/emitters/sse.ts +++ b/packages/client-generator/src/emitters/sse.ts @@ -24,7 +24,7 @@ export function isSseOp(op: OperationModel): boolean { } /** The per-event schema: `itemSchema` → the response `schema` → undefined (typeless slots skipped). */ -function eventSchema(op: OperationModel): SchemaModel | undefined { +export function eventSchema(op: OperationModel): SchemaModel | undefined { const r = sseResponse(op); if (!r) return undefined; if (r.itemSchema && r.itemSchema.kind !== 'unknown') return r.itemSchema; From f93e2de8731d40da0187dbd7359f744e0b516c2f Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 13:23:08 +0300 Subject: [PATCH 050/211] feat(client-generator): text-template type guards, printer-equivalent --- .../__tests__/render-type-guards.test.ts | 80 +++++++++++++++++++ .../src/emitters/type-guards.ts | 42 ++++++++++ 2 files changed, 122 insertions(+) create mode 100644 packages/client-generator/src/emitters/__tests__/render-type-guards.test.ts diff --git a/packages/client-generator/src/emitters/__tests__/render-type-guards.test.ts b/packages/client-generator/src/emitters/__tests__/render-type-guards.test.ts new file mode 100644 index 0000000000..66117c8aed --- /dev/null +++ b/packages/client-generator/src/emitters/__tests__/render-type-guards.test.ts @@ -0,0 +1,80 @@ +import type { NamedSchemaModel } from '../../intermediate-representation/model.js'; +import { printStatements } from '../ts.js'; +import { renderTypeGuards, typeGuardStatements } from '../type-guards.js'; + +// Printer equivalence across the guard vocabulary: explicit discriminators, +// implicit (shared const property), nested unions, multi-value mappings. +const SCHEMAS: NamedSchemaModel[] = [ + { name: 'Beverage', schema: { kind: 'object', properties: [] } }, + { name: 'Dessert', schema: { kind: 'object', properties: [] } }, + { + name: 'MenuItem', + schema: { + kind: 'union', + members: [ + { kind: 'ref', name: 'Beverage' }, + { kind: 'ref', name: 'Dessert' }, + ], + discriminator: { + propertyName: 'category', + mapping: [ + { value: 'beverage', schemaName: 'Beverage' }, + { value: 'iced-beverage', schemaName: 'Beverage' }, + { value: 'dessert', schemaName: 'Dessert' }, + ], + }, + }, + }, + { + name: 'Ok', + schema: { + kind: 'object', + properties: [{ name: 'status', schema: { kind: 'literal', value: 'ok' }, required: true }], + }, + }, + { + name: 'Failed', + schema: { + kind: 'object', + properties: [ + { name: 'status', schema: { kind: 'literal', value: 'failed' }, required: true }, + ], + }, + }, + { + // Implicit discriminator, nested inside an array property. + name: 'BulkResponse', + schema: { + kind: 'object', + properties: [ + { + name: 'results', + schema: { + kind: 'array', + items: { + kind: 'union', + members: [ + { kind: 'ref', name: 'Ok' }, + { kind: 'ref', name: 'Failed' }, + ], + }, + }, + required: true, + }, + ], + }, + }, +] as unknown as NamedSchemaModel[]; + +describe('renderTypeGuards matches printStatements(typeGuardStatements(…))', () => { + it('explicit + implicit + nested + multi-value mappings', () => { + expect(renderTypeGuards(SCHEMAS)).toBe(printStatements(typeGuardStatements(SCHEMAS))); + }); + + it('no guardable unions renders empty', () => { + const plain: NamedSchemaModel[] = [ + { name: 'Order', schema: { kind: 'object', properties: [] } }, + ] as unknown as NamedSchemaModel[]; + expect(renderTypeGuards(plain)).toBe(''); + }); +}); diff --git a/packages/client-generator/src/emitters/type-guards.ts b/packages/client-generator/src/emitters/type-guards.ts index 8b5f43e47e..1394f8c67d 100644 --- a/packages/client-generator/src/emitters/type-guards.ts +++ b/packages/client-generator/src/emitters/type-guards.ts @@ -79,6 +79,48 @@ export function typeGuardStatements(schemas: NamedSchemaModel[]): ts.FunctionDec const { factory } = ts; +/** Text twin of `typeGuardStatements` (printer-equivalence-pinned); same detection, string body. */ +export function renderTypeGuards(schemas: NamedSchemaModel[]): string { + const byName = new Map(schemas.map((s) => [s.name, s.schema] as const)); + const blocks: string[] = []; + const emitted = new Set(); + for (const named of schemas) { + for (const site of collectUnionSites(named)) { + const discriminator = + site.union.discriminator ?? detectImplicitDiscriminator(site.union, byName); + if (!discriminator) continue; + const valuesByTarget = new Map(); + for (const entry of discriminator.mapping) { + if (!byName.has(entry.schemaName)) continue; + const existing = valuesByTarget.get(entry.schemaName); + if (existing) existing.push(entry.value); + else valuesByTarget.set(entry.schemaName, [entry.value]); + } + for (const [schemaName, values] of valuesByTarget) { + const guardName = `is${schemaName}`; + if (emitted.has(guardName)) continue; + emitted.add(guardName); + const access = `(value as Record)[${JSON.stringify(discriminator.propertyName)}]`; + const check = + values.length === 1 + ? `${access} === ${JSON.stringify(values[0])}` + : `([${values.map((value) => JSON.stringify(value)).join(', ')}] as readonly unknown[]).includes(${access})`; + blocks.push( + [ + '/**', + ` * Narrow a \`${site.label}\` to \`${schemaName}\` via its \`${discriminator.propertyName}\` discriminant.`, + ' */', + `export function ${guardName}(value: ${site.label}): value is ${schemaName} {`, + ` return ${check};`, + '}', + ].join('\n') + ); + } + } + } + return blocks.join('\n\n'); +} + /** * The discriminated-union sites reachable from a named schema, in a stable order: * the schema itself (when it is a union), then any nested unions found by walking From 0fa530210938007e86e2a27fecba9a9640829f40 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 13:28:18 +0300 Subject: [PATCH 051/211] =?UTF-8?q?feat(client-generator):=20prepare-time?= =?UTF-8?q?=20runtime=20stripping=20=E2=80=94=20inline=20embedding=20needs?= =?UTF-8?q?=20no=20TypeScript?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scripts/generate-runtime-sources.mjs | 45 ++++++++++++++ .../src/emitters/inline-runtime.ts | 60 ++++--------------- .../src/emitters/runtime-sources.ts | 30 ++++++++++ 3 files changed, 87 insertions(+), 48 deletions(-) diff --git a/packages/client-generator/scripts/generate-runtime-sources.mjs b/packages/client-generator/scripts/generate-runtime-sources.mjs index ad463ec410..29ab7f14be 100644 --- a/packages/client-generator/scripts/generate-runtime-sources.mjs +++ b/packages/client-generator/scripts/generate-runtime-sources.mjs @@ -126,12 +126,57 @@ writeFileSync( ].join('\n') ); +// Stripped variants for inline embedding (emitters/inline-runtime.ts): imports dropped, +// `export` removed except on the kept surface — done HERE at prepare time so the embed +// path needs no TypeScript at generate time. Slices are AST-position-driven (no regexes), +// so comments and formatting survive byte-for-byte — the algorithm previously lived in +// inline-runtime.ts's embedModule and moved here verbatim. +const KEEP_EXPORTS = { + 'types.ts': () => true, + 'errors.ts': (statement) => ts.isClassDeclaration(statement), + 'retry.ts': (statement) => + ts.isFunctionDeclaration(statement) && statement.name?.text === 'defaultRetryOn', + 'setup.ts': () => true, +}; + +function stripModule(name, source) { + const file = ts.createSourceFile('__embed.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const keeps = KEEP_EXPORTS[name]; + const parts = []; + for (const statement of file.statements) { + if (ts.isImportDeclaration(statement)) continue; + const text = source.slice(statement.getFullStart(), statement.end); + const exportModifier = ts + .getModifiers(statement) + ?.find((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); + if (exportModifier && !keeps?.(statement)) { + const at = exportModifier.getStart() - statement.getFullStart(); + parts.push(text.slice(0, at) + text.slice(at + 'export '.length)); + } else { + parts.push(text); + } + } + return parts.join('').trim(); +} + +const strippedEntries = MODULES.map((name) => { + const source = readFileSync(join(runtimeDir, `${name}.ts`), 'utf-8'); + const stripped = stripModule(`${name}.ts`, source); + const line = ` '${name}.ts': ${toStringLiteral(stripped)},`; + return line.length <= 100 ? line : ` '${name}.ts':\n ${toStringLiteral(stripped)},`; +}); + const content = [ '// GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`.', 'export const RUNTIME_SOURCES = {', ...entries, '} as const;', '', + '/** Inline-embed variants: imports dropped, `export` stripped outside the kept surface. */', + 'export const RUNTIME_SOURCES_STRIPPED = {', + ...strippedEntries, + '} as const;', + '', 'export type RuntimeModuleName = keyof typeof RUNTIME_SOURCES;', '', '/** Top-level declared names of the runtime modules — precomputed so the pipeline', diff --git a/packages/client-generator/src/emitters/inline-runtime.ts b/packages/client-generator/src/emitters/inline-runtime.ts index e8b4accffc..efc7e87c10 100644 --- a/packages/client-generator/src/emitters/inline-runtime.ts +++ b/packages/client-generator/src/emitters/inline-runtime.ts @@ -1,11 +1,11 @@ // Assembles the embedded runtime block for inline-mode clients: the real -// `src/runtime/` sources (snapshotted into `RUNTIME_SOURCES`) in import-graph -// order, stripped of module syntax, followed by a local `createClient` factory -// wiring only the capabilities this API needs — the embedded equivalent of the -// package barrel (`runtime/index.ts`), which is never embedded itself. +// `src/runtime/` sources — stripped of module syntax at PREPARE time (see +// scripts/generate-runtime-sources.mjs, which owns the kept-export surface) — in +// import-graph order, followed by a local `createClient` factory wiring only the +// capabilities this API needs. Pure string concatenation: no `typescript` at +// generate time. -import { RUNTIME_SOURCES, type RuntimeModuleName } from './runtime-sources.js'; -import { parseStatements, ts } from './ts.js'; +import { RUNTIME_SOURCES_STRIPPED, type RuntimeModuleName } from './runtime-sources.js'; /** Which optional runtime capabilities the generated client must embed. */ export type InlineRuntimeNeeds = { @@ -19,19 +19,6 @@ export type InlineRuntimeNeeds = { const HEADER = "// ─── Embedded runtime (@redocly/client-generator, assembled per this API's needs) ───"; -// The embedded block keeps `export` only on the surface the generated wiring and its -// type re-exports reference; everything else becomes module-local. `types.ts` is the -// public type surface (it replaces package-mode type imports — and TS `noUnusedLocals` -// never flags exported declarations, so unused types in a given output are fine). -const KEEP_EXPORTS: Partial boolean>> = { - 'types.ts': () => true, - 'errors.ts': ts.isClassDeclaration, // ApiError/TimeoutError stay public; abortError goes local - // defaultRetryOn stays public so custom `retryOn` predicates can compose with it. - 'retry.ts': (statement) => - ts.isFunctionDeclaration(statement) && statement.name?.text === 'defaultRetryOn', - 'setup.ts': () => true, // mergeSetup — the baked-setup wiring calls it -}; - /** The embedded runtime source block: stripped modules in dependency order + the factory. */ export function assembleInlineRuntime(needs: InlineRuntimeNeeds): string { // Import-graph topological order; the optional capability modules slot in where the @@ -46,39 +33,16 @@ export function assembleInlineRuntime(needs: InlineRuntimeNeeds): string { modules.push('send.ts'); if (needs.sse) modules.push('sse.ts'); modules.push('create-client.ts'); - return [HEADER, ...modules.map(embedModule), clientFactory(needs)].join('\n\n'); -} - -// Strip module syntax from one runtime source: drop every import declaration (all are -// relative `./x.js` imports within the runtime) and remove the `export` modifier from -// declarations outside the kept surface. Slices are driven by AST positions from -// `parseStatements` (no regexes), so comments and formatting survive byte-for-byte. -function embedModule(name: RuntimeModuleName): string { - const source = RUNTIME_SOURCES[name]; - const keeps = KEEP_EXPORTS[name]; - const parts: string[] = []; - for (const statement of parseStatements(source)) { - if (ts.isImportDeclaration(statement)) continue; - // Full text includes leading trivia (JSDoc, blank lines), so spacing is preserved. - const text = source.slice(statement.getFullStart(), statement.end); - // Every top-level runtime statement is a declaration (`getModifiers` is total — - // it returns undefined when the node carries no modifiers). - const exportModifier = ts - .getModifiers(statement as ts.HasModifiers) - ?.find((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword); - if (exportModifier && !keeps?.(statement)) { - const at = exportModifier.getStart() - statement.getFullStart(); - parts.push(text.slice(0, at) + text.slice(at + 'export '.length)); - } else { - parts.push(text); - } - } - return parts.join('').trim(); + return [ + HEADER, + ...modules.map((name) => RUNTIME_SOURCES_STRIPPED[name]), + clientFactory(needs), + ].join('\n\n'); } /** The cli engine (`runCli` + types) stripped for embedding into `.cli.ts`. */ export function embedCliRuntime(): string { - return embedModule('cli.ts'); + return RUNTIME_SOURCES_STRIPPED['cli.ts']; } // The embedded equivalent of the package barrel's `createClient`: `createClientCore` diff --git a/packages/client-generator/src/emitters/runtime-sources.ts b/packages/client-generator/src/emitters/runtime-sources.ts index a4ff6d7d3e..1a915de404 100644 --- a/packages/client-generator/src/emitters/runtime-sources.ts +++ b/packages/client-generator/src/emitters/runtime-sources.ts @@ -28,6 +28,36 @@ export const RUNTIME_SOURCES = { "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. */\n positionals: Array<{ name: string; description?: string }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n binName: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const groups = new Set(commands.filter((c) => c.group).map((c) => c.group as string));\n let command: CliCommand | undefined;\n let rest: string[];\n if (groups.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n command = commands.find((c) => c.group === undefined && c.name === argv[0]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]}` };\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nfunction envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [command.group] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${flag.description ?? ''}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope = typeof topic === 'string' ? commands.filter((c) => c.group === topic) : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n if (seenGroups.has(command.group)) continue;\n seenGroups.add(command.group);\n lines.push(` ${command.group} `);\n continue;\n }\n lines.push(\n ` ${[command.group, command.name].filter(Boolean).join(' ')} ${command.summary ?? ''}`.trimEnd()\n );\n }\n lines.push(\n '',\n `Run ${binName} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: CliCommand[],\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(commands, wiring.binName, invocation.topic)) stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(invocation.command.schemas ?? {}, null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", } as const; +/** Inline-embed variants: imports dropped, `export` stripped outside the kept surface. */ +export const RUNTIME_SOURCES_STRIPPED = { + 'types.ts': + "/**\n * The public type surface of the client runtime — `@redocly/client-generator`'s\n * app-facing runtime module. Pure types, no runtime code (excluded from coverage).\n * The generator emits `OPERATIONS` literals typed\n * `satisfies Record` against this module, so an\n * incompatible runtime/generated pair fails the consumer's build (the semver skew guard).\n */\n\n/** How one operation parameter is sent: its location plus OpenAPI query-serialization hints. */\nexport type ParamSpec = {\n name: string;\n in: 'path' | 'query' | 'header' | 'cookie';\n style?: 'form' | 'spaceDelimited' | 'pipeDelimited' | 'deepObject';\n explode?: boolean;\n allowReserved?: boolean;\n};\n\n/** One security scheme, denormalized onto the operation (`scheme` names the spec's scheme). */\nexport type SecuritySpec =\n | { scheme: string; kind: 'bearer' | 'basic' }\n | { scheme: string; kind: 'apiKey'; name: string; in: 'header' | 'query' | 'cookie' };\n\n/**\n * How to auto-iterate a paginated operation (drives its `.pages()`/`.items()` members).\n * `nextCursor` and `items` are RFC 6901 JSON pointers into the page (response) value.\n */\nexport type PaginationSpec =\n | {\n style: 'cursor';\n /** The query param the iterator advances with the response's cursor. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the next cursor in the page. */\n nextCursor: string;\n /** Optional pointer to a boolean \"more pages\" flag — `false` stops iteration. */\n hasMore?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n style: 'offset' | 'page';\n /** The numeric query param the iterator advances. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n /** RFC 8288: follow the response's `Link` header `rel=\"next\"`; stop when absent. */\n style: 'link';\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n };\n\n/** The frozen data contract between generated code and the runtime: one operation's wire shape. */\nexport type OperationDescriptor = {\n id: string;\n method: string;\n path: string;\n tags?: readonly string[];\n params?: readonly ParamSpec[];\n /** `multipart: true` marks a typed object body serialized to FormData by the runtime. */\n body?: { contentType: string; multipart?: boolean };\n /** Defaults to `'json'` (content-type negotiation on parse). */\n responseKind?: 'json' | 'text' | 'blob' | 'void' | 'sse';\n sseDataKind?: 'json' | 'text';\n /** OR-alternatives, each an AND-set: the runtime applies the first fully-configured one. */\n security?: readonly (readonly SecuritySpec[])[];\n pagination?: PaginationSpec;\n};\n\n/** A query value: scalars, arrays of scalars, or objects (serialized as deepObject brackets). */\nexport type QueryValue =\n | string\n | number\n | boolean\n | null\n | undefined\n | Array\n | Record;\n\n/** A credential: a literal, or a (possibly async) function resolved per request (refresh flows). */\nexport type TokenProvider = string | (() => string | Promise);\n\n/** Per-instance credentials, keyed by the scheme kinds the runtime can inject. */\nexport type AuthCredentials = {\n bearer?: TokenProvider;\n basic?: { username: string; password: string };\n apiKey?: Record;\n};\n\n/** Backoff shape: 'fixed' = constant delay; 'exponential' = doubling per attempt. */\nexport type RetryStrategy = 'fixed' | 'exponential';\n\n/**\n * The operation's identity, exposed to middleware for targeting (`ctx.operation`).\n * Generated clients instantiate the type parameters with the spec's literal unions\n * (`OperationId`/`OperationPath`/`OperationTag`) so a misspelled operation id in a\n * middleware comparison fails to compile; the string defaults keep every\n * spec-independent consumer (`runtime-contract.ts`, the runtime internals) working\n * with the base shape. `tags` stays mutable (`Tag[]`) so setup-contract types\n * (byte-locked to generated output) remain assignable through middleware callbacks.\n */\nexport type OperationContext<\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n> = { id: Id; path: Path; tags: Tag[] };\n\n/** The mutable request context threaded through the middleware chain. */\nexport type RequestContext = {\n url: string;\n method: string;\n headers: Record;\n body?: unknown;\n operation: Op;\n};\n\n/** The failed attempt handed to a custom `retryOn`: exactly one of `response`/`error` is set. */\nexport type RetryContext = {\n attempt: number;\n request: RequestContext;\n response?: Response;\n error?: unknown;\n};\n\n/** Opt-in retry policy; a per-call override merges field-by-field over the config policy. */\nexport type RetryConfig = {\n retries?: number;\n retryDelay?: number;\n retryStrategy?: RetryStrategy;\n jitter?: boolean;\n retryOn?: (ctx: RetryContext) => boolean | Promise;\n};\n\n/**\n * Structural stand-in for the runtime's ApiError so this module stays import-free\n * (pure types); the real `ApiError` class is assignable to it.\n */\nexport type ApiErrorLike = globalThis.Error & {\n url: string;\n status: number;\n statusText: string;\n body: unknown;\n};\n\n/** One interceptor: any subset of the three hooks. */\nexport type Middleware = {\n onRequest?: (ctx: RequestContext) => void | Promise;\n onResponse?: (\n response: Response,\n ctx: RequestContext\n ) => Response | void | Promise;\n /** Throw mode only: may map/replace the error. */\n // `globalThis.Error` so a spec schema named `Error` cannot shadow it in inline mode.\n onError?: (\n error: ApiErrorLike,\n ctx: RequestContext\n ) => globalThis.Error | Promise;\n};\n\n/** Client configuration: transport, defaults, retry policy, middleware, and credentials. */\nexport type ClientConfig = {\n serverUrl?: string;\n fetch?: typeof fetch;\n headers?:\n | Record\n | (() => Record | Promise>);\n retry?: RetryConfig;\n /** Milliseconds before a request attempt aborts (covers the body read too; each retry\n * attempt gets a fresh budget). Per-call `timeout` overrides it, `0` disables it.\n * SSE streams are long-lived by design and never inherit this value. */\n timeout?: number;\n /** Send an `Idempotency-Key` header on POST/PATCH (one stable key per logical call,\n * reused across retry attempts) — which also makes those retries safe under the\n * default retry policy. `true` generates a UUID per call; a function supplies the key. */\n idempotencyKey?: boolean | (() => string);\n /** Identifies this client to the API via an `X-Redocly-Client` header (the generator\n * bakes a default). Sent only OUTSIDE browsers — a custom header would force a CORS\n * preflight. Override with your own value, or `false` to disable. */\n clientHeader?: string | false;\n middleware?: Middleware[];\n auth?: AuthCredentials;\n /** Fixed at generate time by the generator (`'throw'` when omitted); `configure()` ignores it. */\n errorMode?: 'throw' | 'result';\n onRequest?: Middleware['onRequest'];\n onResponse?: Middleware['onResponse'];\n onError?: Middleware['onError'];\n};\n\n/** Response readers for the per-call `parseAs` override. */\nexport type ParseAs = 'auto' | 'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData' | 'stream';\n\n/** Per-call options: standard `RequestInit` plus a retry override, a timeout override\n * (`0` disables the config default), and a forced reader. */\nexport type RequestOptions = RequestInit & {\n retry?: RetryConfig;\n timeout?: number;\n /** Per-call idempotency key: a literal key, `true` to generate one, `false` to skip. */\n idempotencyKey?: string | boolean | (() => string);\n parseAs?: ParseAs;\n};\n\n/** Per-call options for an SSE stream; reconnect defaults to true. */\nexport type SseOptions = RequestInit & { reconnect?: boolean; reconnectDelay?: number };\n\n/** A single decoded Server-Sent Event with its payload typed from the spec. */\nexport type ServerSentEvent = { event?: string; data: T; id?: string; retry?: number };\n\n/** Result-mode return shape: exactly one of `data`/`error` is set. */\nexport type Result =\n | { data: TData; error: undefined; response: Response }\n | { data: undefined; error: TError; response: Response };\n\n/**\n * The generated `Ops` type's shape: per-operation args/result, plus `kind: 'sse'` for\n * streams and, for paginated operations, `item` (the page's element type) and — on\n * result-mode clients only — `page` (the RAW page type `.pages()` yields, since\n * iteration unwraps the `Result` envelope the one-shot `result` carries).\n */\nexport type OpsShape = Record<\n string,\n { args: object; result: unknown; kind?: 'sse'; item?: unknown; page?: unknown }\n>;\n\n/** The always-present client members (assigned after the operation loop — they win collisions). */\nexport type ClientCore = {\n /** Merge into the config; note `middleware` REPLACES the chain (use `use()` to compose). */\n configure(config: ClientConfig): void;\n /** Append interceptors (composes with baked/publisher middleware). */\n use(...middleware: Middleware[]): void;\n auth: {\n bearer(token: TokenProvider): void;\n basic(username: string, password: string): void;\n apiKey(scheme: string, value: TokenProvider): void;\n };\n};\n\n/**\n * The standard TypeScript optionality probe: `{}` has no required members, so\n * `{} extends A` is true exactly when every member of `A` is optional.\n */\n// oxlint-disable-next-line typescript/no-empty-object-type\ntype NoRequiredKeys = {} extends A ? true : false;\n\n/**\n * The page type `.pages()` yields: the RAW page declared by `page` (the generator\n * writes it only on result-mode paginated entries, whose `result` is the envelope),\n * or the method's own `result` (throw mode — already the raw page).\n */\ntype PageOf = Entry extends { page: unknown }\n ? Entry['page']\n : Entry['result'];\n\n/**\n * The auto-pagination members intersected onto a paginated method — present exactly when\n * the Ops entry declares `item` (the generator writes it only for paginated operations).\n * Args optionality mirrors the method's own; `unknown` otherwise (identity under `&`).\n * Iteration is error-mode-agnostic: `.pages()`/`.items()` yield raw pages/items, and a\n * failed page aborts iteration by throwing `ApiError`, even on result-mode clients; the\n * `onError` middleware hook (throw-mode-only) is not invoked.\n */\ntype Paginated = 'item' extends keyof Entry\n ? NoRequiredKeys extends true\n ? {\n pages(args?: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args?: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : {\n pages(args: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : unknown;\n\n/**\n * The stable identity every client method carries: the SPEC operationId (also set as\n * `fn.name`, but `operationId` is the explicit, minification-proof form) — a robust\n * cache key for consumer wrappers (react-query keys and the like).\n */\nexport type OperationMethodIdentity = { readonly operationId: string };\n\n/** The typed instance client: one bound method per operation plus the core members. */\nexport type Client = {\n [K in keyof Ops]: Ops[K] extends { kind: 'sse' }\n ? (NoRequiredKeys extends true\n ? (\n args?: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>\n : (\n args: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>) &\n OperationMethodIdentity\n : (NoRequiredKeys extends true\n ? (args?: Ops[K]['args'], init?: RequestOptions) => Promise\n : (args: Ops[K]['args'], init?: RequestOptions) => Promise) &\n OperationMethodIdentity &\n Paginated;\n} & ClientCore;", + 'errors.ts': + "/** The error thrown (throw mode) for a non-2xx response, carrying the decoded error body. */\nexport class ApiError extends Error {\n public readonly url: string;\n public readonly status: number;\n public readonly statusText: string;\n public readonly body: unknown;\n constructor(url: string, status: number, statusText: string, body: unknown) {\n super(`Request failed with status ${status}`);\n this.name = 'ApiError';\n this.url = url;\n this.status = status;\n this.statusText = statusText;\n this.body = body;\n }\n}\n\n/** The error thrown when a request attempt exceeds the configured `timeout` — carries\n * the context a log line needs (which operation, what budget, which attempt). */\nexport class TimeoutError extends Error {\n public readonly operationId: string;\n public readonly timeout: number;\n public readonly attempt: number;\n constructor(operationId: string, timeout: number, attempt: number) {\n super(`Request \"${operationId}\" timed out after ${timeout} ms (attempt ${attempt})`);\n this.name = 'TimeoutError';\n this.operationId = operationId;\n this.timeout = timeout;\n this.attempt = attempt;\n }\n}\n\n/** The error to throw for an aborted request: the caller's abort reason when it is an Error. */\n// `globalThis.Error` (not bare `Error`) so a spec schema named `Error` cannot shadow it\n// when this module is embedded alongside generated types (inline mode).\nfunction abortError(signal: AbortSignal): globalThis.Error {\n const reason = (signal as { reason?: unknown }).reason;\n if (reason instanceof Error) return reason;\n return new DOMException('The operation was aborted.', 'AbortError');\n}", + 'url.ts': + "/**\n * The RESOLVED OpenAPI serialization spec for one query parameter — callers apply the\n * OpenAPI defaults (`style: 'form'`, `explode: true`) before building one.\n */\ntype QueryStyle = {\n style: NonNullable;\n explode: boolean;\n allowReserved?: boolean;\n};\n\n/**\n * Encode everything except the RFC-3986 reserved set, for `allowReserved: true` params —\n * `filter=a/b` survives instead of `filter=a%2Fb`.\n */\nfunction encodeReserved(value: string): string {\n return encodeURIComponent(value).replace(\n /%(3A|2F|3F|23|5B|5D|40|21|24|26|27|28|29|2A|2B|2C|3B|3D)/g,\n (match) => decodeURIComponent(match)\n );\n}\n\n/** Substitute `{name}` template segments with encoded values; a missing value is a caller bug. */\nfunction substitutePath(template: string, values: Record): string {\n return template.replace(/\\{([^{}]+)\\}/g, (_match, name: string) => {\n const value = values[name];\n if (value === undefined) throw new Error(`Missing path parameter \"${name}\"`);\n return encodeURIComponent(String(value));\n });\n}\n\n/**\n * Build the request URL: `serverUrl` (trailing slash trimmed) + path + serialized query.\n * Query parameters honor their OpenAPI `style`/`explode`/`allowReserved` (from `styles`);\n * without a spec, arrays repeat the key (`form`+`explode`), objects serialize as\n * `deepObject` brackets, and `null`/`undefined` entries are skipped.\n */\nfunction buildUrl(\n serverUrl: string,\n path: string,\n query?: Record,\n styles?: Record\n): string {\n // Trim trailing slashes with a scan, not `/\\/+$/` — an anchored `+` regex is\n // quadratic on adversarial many-slash input (the server URL is caller data).\n let end = serverUrl.length;\n while (end > 0 && serverUrl.charCodeAt(end - 1) === 47 /* '/' */) end--;\n const url = serverUrl.slice(0, end) + path;\n if (!query) return url;\n const params = new URLSearchParams();\n const raw: string[] = [];\n for (const [key, value] of Object.entries(query)) {\n if (value === undefined || value === null) continue;\n const spec = styles?.[key];\n if (!spec) {\n if (Array.isArray(value)) {\n for (const v of value) {\n if (v !== undefined && v !== null) params.append(key, String(v));\n }\n } else if (Object(value) === value) {\n // Object-valued query params use `deepObject` style: key[subKey]=subValue.\n for (const [subKey, subValue] of Object.entries(value)) {\n if (subValue !== undefined && subValue !== null) {\n params.append(`${key}[${subKey}]`, String(subValue));\n }\n }\n } else {\n params.append(key, String(value));\n }\n continue;\n }\n if (Array.isArray(value)) {\n const items = value.filter((v) => v !== undefined && v !== null).map(String);\n if (spec.style === 'form' && spec.explode) {\n for (const v of items) {\n if (spec.allowReserved) raw.push(`${key}=${encodeReserved(v)}`);\n else params.append(key, v);\n }\n } else {\n // Delimited styles put the LITERAL delimiter on the wire; only the\n // values are encoded. `%20` (not `+`) is the literal space delimiter.\n const delim =\n spec.style === 'pipeDelimited' ? '|' : spec.style === 'spaceDelimited' ? '%20' : ',';\n const enc = spec.allowReserved ? encodeReserved : encodeURIComponent;\n raw.push(`${encodeURIComponent(key)}=${items.map(enc).join(delim)}`);\n }\n } else if (Object(value) === value) {\n // `deepObject` (and any object spec, for now): key[subKey]=subValue.\n for (const [subKey, subValue] of Object.entries(value)) {\n if (subValue !== undefined && subValue !== null) {\n if (spec.allowReserved) raw.push(`${key}[${subKey}]=${encodeReserved(String(subValue))}`);\n else params.append(`${key}[${subKey}]`, String(subValue));\n }\n }\n } else if (spec.allowReserved) {\n raw.push(`${key}=${encodeReserved(String(value))}`);\n } else {\n params.append(key, String(value));\n }\n }\n const qs = [params.toString(), ...raw].filter(Boolean).join('&');\n return qs ? `${url}?${qs}` : url;\n}", + 'parse.ts': + "/**\n * Read the response body per `kind`. `'auto'` negotiates from the content type\n * (JSON, then `text/*`, then Blob); `204` responses read nothing. A `'void'`\n * operation (no declared 2xx content) still returns a JSON body the server\n * actually sends: the static type stays `void`, but silently dropping real data\n * behind a spec gap is the worse failure — consumers can reach it with a cast\n * while the API description catches up.\n */\nasync function parse(response: Response, kind: ParseAs | 'void'): Promise {\n if (kind === 'void') {\n if (response.status === 204 || response.status === 205 || response.status === 304) {\n return undefined;\n }\n const contentType = (response.headers.get('content-type') ?? '').toLowerCase();\n if (!contentType.includes('json')) return undefined;\n // Best-effort: an empty or malformed body on an undeclared response stays undefined.\n const text = await response.text().catch(() => '');\n if (text === '') return undefined;\n try {\n return JSON.parse(text);\n } catch {\n return undefined;\n }\n }\n if (response.status === 204) return undefined;\n if (kind === 'stream') return response.body;\n if (kind === 'blob') return response.blob();\n if (kind === 'arrayBuffer') return response.arrayBuffer();\n if (kind === 'formData') return response.formData();\n if (kind === 'text') return response.text();\n if (kind === 'json') return response.json();\n // 'auto' — negotiate from the response's content type (case-insensitively:\n // `Text/Plain` and `application/JSON` are valid per RFC 9110).\n const contentType = (response.headers.get('content-type') ?? '').toLowerCase();\n if (contentType.includes('json')) return response.json();\n if (contentType.startsWith('text/')) return response.text();\n // An untyped body reads as a Blob — but an EMPTY one resolves to undefined: a 2xx\n // with `Content-Length: 0` must not yield a truthy `new Blob([])` that silently\n // defeats every `!data` guard downstream.\n const blob = await response.blob();\n return blob.size > 0 ? blob : undefined;\n}\n\n/** Best-effort decode of a non-2xx body (JSON when declared, else text; undefined on failure). */\nasync function readError(response: Response): Promise {\n const contentType = response.headers.get('content-type') ?? '';\n if (contentType.toLowerCase().includes('json')) {\n return response.json().catch(() => undefined);\n }\n return response.text().catch(() => undefined);\n}", + 'retry.ts': + "const IDEMPOTENT_METHODS = new Set(['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS']);\nconst TRANSIENT_STATUS = new Set([408, 429, 500, 502, 503, 504]);\n\n/**\n * The default retry predicate: idempotent methods — or any request carrying an\n * `Idempotency-Key` header, which makes re-sending safe — on a transport error or a\n * transient status. A custom `retryOn` fully replaces this (no method check kept).\n */\nexport function defaultRetryOn(ctx: RetryContext): boolean {\n const safeToResend =\n IDEMPOTENT_METHODS.has(ctx.request.method.toUpperCase()) ||\n 'Idempotency-Key' in ctx.request.headers ||\n 'idempotency-key' in ctx.request.headers;\n if (!safeToResend) return false;\n return ctx.response === undefined || TRANSIENT_STATUS.has(ctx.response.status);\n}\n\n/**\n * The delay before the next attempt: a `Retry-After` header (seconds or HTTP-date)\n * wins; otherwise fixed/exponential backoff over `retryDelay`, with full jitter\n * unless `jitter === false`.\n */\nfunction retryDelay(retry: RetryConfig, attempt: number, retryAfter: string | null): number {\n if (retryAfter) {\n const seconds = Number(retryAfter);\n if (!Number.isNaN(seconds)) return seconds * 1000;\n const when = Date.parse(retryAfter);\n if (!Number.isNaN(when)) return Math.max(0, when - Date.now());\n }\n const base = retry.retryDelay ?? 1000;\n const raw = retry.retryStrategy === 'fixed' ? base : base * Math.pow(2, attempt - 1);\n return retry.jitter === false ? raw : Math.random() * raw;\n}\n\n/** Abort-aware sleep: resolves after `ms`, rejects with the abort reason immediately on abort. */\nfunction sleep(ms: number, signal?: AbortSignal): Promise {\n return new Promise((resolve, reject) => {\n if (signal?.aborted) {\n reject(abortError(signal));\n return;\n }\n const onAbort = () => {\n clearTimeout(timer);\n reject(abortError(signal as AbortSignal));\n };\n const timer = setTimeout(() => {\n if (signal) signal.removeEventListener('abort', onAbort);\n resolve();\n }, ms);\n if (signal) signal.addEventListener('abort', onAbort, { once: true });\n });\n}", + 'multipart.ts': + "/**\n * Serialize a plain object into `FormData` for a typed `multipart/form-data` body\n * (capability module — wired into `createClient`, never imported by the send core).\n * `Blob`/`File` and strings pass through; `Date`s become ISO strings; arrays append\n * one field per item; other objects are JSON-encoded; everything else is stringified.\n * `undefined`/`null` entries are skipped.\n */\nfunction toFormData(body: Record): FormData {\n const fd = new FormData();\n const append = (key: string, value: unknown): void => {\n if (value === undefined || value === null) return;\n if (value instanceof Blob || typeof value === 'string') fd.append(key, value);\n else if (value instanceof Date) fd.append(key, value.toISOString());\n else if (Object(value) === value) fd.append(key, JSON.stringify(value));\n else fd.append(key, String(value));\n };\n for (const [key, value] of Object.entries(body)) {\n if (Array.isArray(value)) for (const item of value) append(key, item);\n else append(key, value);\n }\n return fd;\n}", + 'auth.ts': + "/** Resolve a credential: a literal passes through; a function is awaited per request. */\nasync function resolveToken(provider: TokenProvider): Promise {\n return typeof provider === 'function' ? await provider() : provider;\n}\n\n/** UTF-8-safe base64: bare `btoa` throws on non-Latin-1 credentials (RFC 7617 allows UTF-8). */\nfunction encodeBase64(text: string): string {\n let binary = '';\n for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte);\n return btoa(binary);\n}\n\n/** Whether a credential for this scheme is configured on the instance. */\nfunction isConfigured(scheme: SecuritySpec, config: ClientConfig): boolean {\n if (scheme.kind === 'apiKey') return config.auth?.apiKey?.[scheme.scheme] !== undefined;\n if (scheme.kind === 'bearer') return config.auth?.bearer !== undefined;\n return config.auth?.basic !== undefined;\n}\n\n/**\n * Build the auth headers/query for one operation's `security` OR-alternatives from the\n * instance credentials (`config.auth`) — capability module, wired into `createClient`.\n * The first alternative whose schemes (an AND-set) are all configured is applied, so\n * \"bearer OR apiKey\" works with either credential and never sends both. When none is\n * fully configured, the first alternative's configured schemes are still sent (the\n * server rejects the request, mirroring the previous behavior).\n * Cookie-borne apiKeys fold into a single `Cookie` header joined with `; `.\n */\nasync function resolveAuth(\n security: readonly (readonly SecuritySpec[])[],\n config: ClientConfig\n): Promise<{ headers: Record; query: Record }> {\n const alternative =\n security.find((schemes) => schemes.every((scheme) => isConfigured(scheme, config))) ??\n security[0] ??\n [];\n const headers: Record = {};\n const query: Record = {};\n const cookies: string[] = [];\n for (const scheme of alternative) {\n if (scheme.kind === 'apiKey') {\n const provider = config.auth?.apiKey?.[scheme.scheme];\n if (provider === undefined) continue;\n const value = await resolveToken(provider);\n if (scheme.in === 'header') headers[scheme.name] = value;\n else if (scheme.in === 'query') query[scheme.name] = value;\n // Cookie values may contain reserved characters (`;`, `=`, space, …); percent-encode\n // so the credential can't break the `Cookie` header syntax.\n else cookies.push(`${scheme.name}=${encodeURIComponent(value)}`);\n } else if (scheme.kind === 'bearer') {\n const provider = config.auth?.bearer;\n if (provider !== undefined) headers.Authorization = `Bearer ${await resolveToken(provider)}`;\n } else {\n const basic = config.auth?.basic;\n if (basic !== undefined) {\n headers.Authorization = `Basic ${encodeBase64(`${basic.username}:${basic.password}`)}`;\n }\n }\n }\n if (cookies.length > 0) headers.Cookie = cookies.join('; ');\n return { headers, query };\n}", + 'setup.ts': + "/**\n * Merge a publisher's baked setup (`defineClientSetup({...})`) with the app's config:\n * app config fields win per-field over baked defaults, while middleware composes —\n * baked middleware runs first, then the app's.\n */\nexport function mergeSetup(\n setup: { config?: ClientConfig; middleware?: Middleware[] } | undefined,\n config: ClientConfig = {}\n): ClientConfig {\n return {\n ...setup?.config,\n ...config,\n middleware: [...(setup?.middleware ?? []), ...(config.middleware ?? [])],\n };\n}", + 'send.ts': + "/**\n * Optional behaviors the send core can use but never statically imports — wired by\n * `createClient` (the same seam the future inline-mode assembler relies on).\n */\ntype SendCapabilities = {\n /** Serialize a typed multipart body (a plain object) to FormData. */\n serializeMultipart?: (body: Record) => FormData;\n};\n\n/**\n * Normalize a caller's `HeadersInit` (plain record, `Headers` instance, or entry pairs)\n * to a plain record — spreading a `Headers` or an array contributes no entries.\n */\nfunction toHeaderRecord(headers: HeadersInit | undefined): Record {\n if (headers === undefined) return {};\n if (headers instanceof Headers) {\n const record: Record = {};\n headers.forEach((value, key) => {\n record[key] = value;\n });\n return record;\n }\n if (Array.isArray(headers)) return Object.fromEntries(headers);\n return headers;\n}\n\n/**\n * The effective middleware chain for a request: the single `onRequest`/`onResponse`/\n * `onError` config hooks as one implicit first middleware, then `config.middleware`.\n */\nfunction middlewareChain(config: ClientConfig): Middleware[] {\n const single =\n config.onRequest || config.onResponse || config.onError\n ? [{ onRequest: config.onRequest, onResponse: config.onResponse, onError: config.onError }]\n : [];\n return [...single, ...(config.middleware ?? [])];\n}\n\n/**\n * The fetch core shared by every operation: default + config + per-call headers, the\n * `onRequest` chain (BEFORE body serialization, so mutations are sent), body\n * serialization (JSON, or FormData via the multipart capability), the retry loop\n * (idempotent-only defaults, `Retry-After`, abandoned-body drain), and the reverse\n * `onResponse` onion. Returns the final response plus the request context.\n */\nasync function send(\n config: ClientConfig,\n op: OperationContext,\n url: string,\n init: RequestOptions,\n body: unknown | undefined,\n bodySpec: { contentType: string; multipart?: boolean } | undefined,\n caps: SendCapabilities,\n accept = 'application/json'\n): Promise<{ response: Response; context: RequestContext }> {\n const { retry: callRetry, timeout: callTimeout, idempotencyKey: callKey, ...fetchInit } = init;\n const retry: RetryConfig = { ...config.retry, ...callRetry };\n const timeout = callTimeout ?? config.timeout;\n const idempotency = callKey ?? config.idempotencyKey;\n const extra = typeof config.headers === 'function' ? await config.headers() : config.headers;\n const headers: Record = {\n Accept: accept,\n ...extra,\n ...toHeaderRecord(fetchInit.headers),\n };\n const method = (fetchInit.method ?? 'GET').toUpperCase();\n // One stable key per LOGICAL call — set before the retry loop so every attempt\n // re-sends the same key; a caller-provided header always wins.\n if (\n idempotency !== undefined &&\n idempotency !== false &&\n (method === 'POST' || method === 'PATCH') &&\n !('Idempotency-Key' in headers) &&\n !('idempotency-key' in headers)\n ) {\n headers['Idempotency-Key'] =\n typeof idempotency === 'string'\n ? idempotency\n : typeof idempotency === 'function'\n ? idempotency()\n : crypto.randomUUID();\n }\n // Client identification for the API owner's telemetry — never in browsers, where a\n // custom header would force a CORS preflight the API may not allow.\n if (\n typeof config.clientHeader === 'string' &&\n typeof document === 'undefined' &&\n !('X-Redocly-Client' in headers) &&\n !('x-redocly-client' in headers)\n ) {\n headers['X-Redocly-Client'] = config.clientHeader;\n }\n const context: RequestContext = {\n url,\n method: fetchInit.method ?? 'GET',\n headers,\n body,\n operation: op,\n };\n const middleware = middlewareChain(config);\n for (const mw of middleware) if (mw.onRequest) await mw.onRequest(context);\n // Serialize AFTER onRequest so body mutations (case conversion, enveloping, signing) take effect.\n let payload: BodyInit | undefined;\n if (context.body !== undefined) {\n const value = context.body;\n const isBinary =\n value instanceof Blob ||\n value instanceof ArrayBuffer ||\n ArrayBuffer.isView(value as ArrayBufferView);\n const isFormData = typeof FormData !== 'undefined' && value instanceof FormData;\n const isURLSearchParams = value instanceof URLSearchParams;\n if (isFormData || isURLSearchParams || isBinary || typeof value === 'string') {\n payload = value as BodyInit;\n } else if (bodySpec?.multipart === true) {\n if (!caps.serializeMultipart) {\n throw new Error('Multipart capability not wired: cannot serialize the request body');\n }\n payload = caps.serializeMultipart(value as Record);\n } else {\n payload = JSON.stringify(value);\n if (!('Content-Type' in context.headers) && !('content-type' in context.headers)) {\n // The spec's declared request content type (e.g. application/merge-patch+json).\n context.headers['Content-Type'] = bodySpec?.contentType ?? 'application/json';\n }\n }\n }\n const doFetch = config.fetch ?? fetch;\n const maxAttempts = 1 + (retry.retries ?? 0);\n const retryOn = retry.retryOn ?? defaultRetryOn;\n const signal = fetchInit.signal ?? undefined;\n\n let attempt = 0;\n while (true) {\n attempt++;\n if (signal?.aborted) throw abortError(signal);\n // A fresh timeout budget per attempt; the caller's signal still wins the race.\n // The composed signal also governs reading the response body.\n const attemptSignal = timeout\n ? signal\n ? AbortSignal.any([signal, AbortSignal.timeout(timeout)])\n : AbortSignal.timeout(timeout)\n : signal;\n let response: Response;\n try {\n response = await doFetch(context.url, {\n ...fetchInit,\n signal: attemptSignal,\n method: context.method,\n headers: context.headers,\n body: payload,\n });\n } catch (error) {\n if (\n attempt < maxAttempts &&\n !signal?.aborted &&\n (await retryOn({ attempt, request: context, error }))\n ) {\n await sleep(retryDelay(retry, attempt, null), signal);\n continue;\n }\n // Our timeout fired (never the caller's own abort — that rethrows untouched):\n // wrap the bare DOMException with the context a log line needs.\n if (\n timeout &&\n !signal?.aborted &&\n error instanceof DOMException &&\n error.name === 'TimeoutError'\n ) {\n throw new TimeoutError(op.id, timeout, attempt);\n }\n throw error;\n }\n // Reverse order: the last-registered middleware wraps closest to the network (onion).\n for (let i = middleware.length - 1; i >= 0; i--) {\n const onResponse = middleware[i].onResponse;\n if (onResponse) {\n const replaced = await onResponse(response, context);\n if (replaced && replaced !== response) {\n // Cancel the abandoned original's body — like the retry path, an unread body\n // keeps its connection checked out under Node/undici.\n await response.body?.cancel().catch(() => undefined);\n response = replaced;\n }\n }\n }\n if (\n !response.ok &&\n attempt < maxAttempts &&\n !signal?.aborted &&\n (await retryOn({ attempt, request: context, response }))\n ) {\n const retryAfter = response.headers.get('retry-after');\n // Drain the abandoned response body before the next attempt: an unread body\n // keeps the connection checked out (and can stall the pool) under Node/undici\n // and other strict HTTP clients. Ignore errors (e.g. a middleware already read it).\n await response.body?.cancel().catch(() => undefined);\n await sleep(retryDelay(retry, attempt, retryAfter), signal);\n continue;\n }\n return { response, context };\n }\n}", + 'sse.ts': + "/**\n * A frame delimiter: two consecutive line terminators (each CR, LF, or CRLF, per the SSE\n * spec — so mixed endings like `\\n\\r\\n` are valid boundaries, not just matching pairs).\n */\nconst FRAME_DELIMITER = /(?:\\r\\n|\\r|\\n){2}/;\n\n/**\n * A terminally malformed event stream — unparseable JSON `data` or an unbounded frame.\n * A stable bad payload, not a dropped connection, so the stream never reconnects on it.\n */\nclass SseParseError extends Error {}\n\n/**\n * Consume a `text/event-stream` operation as typed events (capability module — wired\n * into `createClient`). Auto-reconnects on dropped connections, resuming from the last\n * seen event id via `Last-Event-ID` (backoff: the server's `retry:` value, then\n * `reconnectDelay`, then 1s — exponential with jitter, capped at 30s). A clean stream\n * end flushes a trailing frame and finishes; `break`/abort end the iterator cleanly.\n */\nasync function* sse(\n config: ClientConfig,\n op: OperationContext,\n prepare: () => Promise<{ url: string; init: SseOptions; body?: unknown }>,\n dataKind: 'json' | 'text' = 'text'\n): AsyncGenerator> {\n let lastEventId: string | undefined;\n let serverRetry: number | undefined;\n let failures = 0;\n while (true) {\n // Re-prepare each attempt so a refresh-style TokenProvider yields a fresh credential\n // on reconnect (the auth is baked into `url` query + `init.headers`). `reconnect`,\n // `reconnectDelay`, and `signal` come from the caller's original options unchanged.\n const { url, init, body: requestBody } = await prepare();\n const { reconnect = true, reconnectDelay, ...rest } = init;\n const signal = rest.signal ?? undefined;\n if (signal?.aborted) return;\n const headers: Record = {\n Accept: 'text/event-stream',\n ...toHeaderRecord(rest.headers),\n };\n const sendHeaders =\n lastEventId === undefined ? headers : { ...headers, 'Last-Event-ID': lastEventId };\n try {\n const { response } = await send(\n config,\n op,\n url,\n // `timeout: 0` opts the stream out of a config-level timeout — an event stream\n // is long-lived by design and must not be severed after N milliseconds.\n { ...rest, method: rest.method ?? 'GET', headers: sendHeaders, timeout: 0 },\n requestBody,\n undefined,\n {}\n );\n if (!response.ok) {\n const errorBody = await readError(response);\n throw new ApiError(url, response.status, response.statusText, errorBody);\n }\n failures = 0;\n const body = response.body;\n if (!body) return;\n const reader = body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n try {\n while (true) {\n const { done, value } = await reader.read();\n buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });\n let match: RegExpExecArray | null;\n while ((match = FRAME_DELIMITER.exec(buffer)) !== null) {\n const raw = buffer.slice(0, match.index);\n buffer = buffer.slice(match.index + match[0].length);\n const event = parseSseFrame(raw, dataKind);\n if (event) {\n if (event.id !== undefined) lastEventId = event.id;\n if (event.retry !== undefined) serverRetry = event.retry;\n yield event as ServerSentEvent;\n }\n }\n if (done) {\n // Stream closed cleanly. Flush a final event that arrived without a trailing\n // delimiter, then finish — a clean end is not a dropped connection, so do not reconnect.\n const event = buffer.length > 0 ? parseSseFrame(buffer, dataKind) : undefined;\n if (event) {\n if (event.id !== undefined) lastEventId = event.id;\n if (event.retry !== undefined) serverRetry = event.retry;\n yield event as ServerSentEvent;\n }\n return;\n }\n // Bound memory: a server that never sends a frame delimiter would otherwise\n // grow `buffer` without limit. 1 MiB is far above any real SSE frame.\n if (buffer.length > 1048576) {\n throw new SseParseError('SSE frame exceeded 1048576 characters without a delimiter');\n }\n }\n } finally {\n await reader.cancel().catch(() => undefined);\n }\n } catch (error) {\n if (signal?.aborted) return;\n // A non-OK HTTP response (4xx/5xx) or an unparseable JSON payload is a definitive\n // error, not a transient drop — surface it instead of reconnecting in a loop (a\n // stable bad payload would otherwise reconnect forever).\n if (error instanceof ApiError || error instanceof SseParseError) throw error;\n // A transport failure (connect/DNS/reset) when opening the request, or a mid-stream\n // read error, is a dropped connection: fall through to backoff/reconnect when enabled.\n if (!reconnect) throw error;\n }\n // Only the swallowed-drop path reaches here: reconnect is on and the signal not aborted.\n failures++;\n const base = serverRetry ?? reconnectDelay ?? 1000;\n const delay = Math.min(base * Math.pow(2, failures - 1), 30_000);\n try {\n await sleep(Math.random() * delay, signal);\n } catch {\n return; // sleep rejects only on abort — end the iterator cleanly\n }\n }\n}\n\n/** Parse one raw SSE frame (its lines) into an event; returns undefined for comment-only frames. */\nfunction parseSseFrame(\n raw: string,\n dataKind: 'json' | 'text'\n): ServerSentEvent | undefined {\n let event: string | undefined;\n const dataLines: string[] = [];\n let id: string | undefined;\n let retry: number | undefined;\n let sawField = false;\n for (const line of raw.split(/\\r\\n|\\n|\\r/)) {\n if (line === '' || line.startsWith(':')) continue;\n const colon = line.indexOf(':');\n const field = colon === -1 ? line : line.slice(0, colon);\n let val = colon === -1 ? '' : line.slice(colon + 1);\n if (val.startsWith(' ')) val = val.slice(1);\n sawField = true;\n if (field === 'event') event = val;\n else if (field === 'data') dataLines.push(val);\n else if (field === 'id') id = val;\n else if (field === 'retry') {\n // ASCII digits only, per the EventSource spec — anything else is ignored\n // (`Number('')` is 0 and would zero the reconnect backoff).\n if (/^\\d+$/.test(val)) retry = Number(val);\n }\n }\n if (!sawField) return undefined;\n const dataText = dataLines.join('\\n');\n let data: unknown = dataText;\n if (dataKind === 'json' && dataText !== '') {\n try {\n data = JSON.parse(dataText);\n } catch (error) {\n throw new SseParseError(\n `Failed to parse SSE event data as JSON: ${error instanceof Error ? error.message : String(error)}`\n );\n }\n }\n return { event, data, id, retry };\n}", + 'create-client.ts': + "/**\n * The optional behaviors `createClientCore` can dispatch to but never statically\n * imports. The package's public `createClient` wires the full set; the future\n * inline-mode assembler wires only the capabilities a spec needs.\n */\ntype Capabilities = SendCapabilities & {\n resolveAuth?: (\n security: readonly (readonly SecuritySpec[])[],\n config: ClientConfig\n ) => Promise<{ headers: Record; query: Record }>;\n sse?: (\n config: ClientConfig,\n op: OperationContext,\n // Re-preparing per (re)connect (not a frozen url/init) lets a refresh-style\n // TokenProvider issue a fresh credential after a dropped stream reconnects.\n prepare: () => Promise<{ url: string; init: SseOptions }>,\n dataKind: 'json' | 'text'\n ) => AsyncGenerator>;\n paginate?: {\n pages: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n items: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n // The `link`-style iterators need the raw `Link` header + page URL, which the\n // parsed-page call above cannot carry (the shape mirrors paginate's `LinkPageCall`).\n pagesByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n itemsByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n };\n};\n\n/** The grouped args wire shape: path params by name plus the `params`/`body`/`headers`/`cookies` slots. */\ntype OperationArgs = {\n params?: Record;\n body?: unknown;\n headers?: Record;\n cookies?: Record;\n} & Record;\n\n/** The response reader implied by the descriptor (before any per-call `parseAs` override). */\n/**\n * The `Accept` header matching how the response will be read — a blob/text operation\n * must not ask for `application/json` (a content-negotiating server would 406 or\n * answer with a JSON error body instead of the payload). Caller `init.headers` and\n * `config.headers` still override.\n */\nfunction acceptFor(kind: ParseAs | 'void'): string {\n if (kind === 'text') return 'text/*';\n if (kind === 'blob' || kind === 'arrayBuffer' || kind === 'stream' || kind === 'formData') {\n return '*/*';\n }\n return 'application/json'; // json | auto | void\n}\n\nfunction kindFor(op: OperationDescriptor): ParseAs | 'void' {\n if (op.responseKind === 'void' || op.responseKind === 'blob' || op.responseKind === 'text') {\n return op.responseKind;\n }\n return 'auto';\n}\n\n/** Route the grouped args by the descriptor: path values, query object, body, extra headers, cookies. */\nfunction splitArgs(op: OperationDescriptor, args: OperationArgs) {\n const path: Record = {};\n const pathNames = new Set();\n for (const param of op.params ?? []) {\n if (param.in === 'path') {\n pathNames.add(param.name);\n path[param.name] = args[param.name];\n }\n }\n // An unknown top-level key can only be a bug (usually a flat-style call shape passed\n // to a grouped client: `{ limit: 10 }` instead of `{ params: { limit: 10 } }`).\n // TypeScript catches it, but transpilers that skip type-checking would otherwise\n // ship a request that silently drops the value — fail the call loudly instead.\n for (const key of Object.keys(args)) {\n if (key === 'params' || key === 'body' || key === 'headers' || key === 'cookies') continue;\n if (pathNames.has(key)) continue;\n throw new TypeError(\n `Unknown argument \"${key}\" for operation \"${op.id}\". Query parameters go under params: { … } and the request body under body; valid keys are params, body, headers, cookies` +\n (pathNames.size > 0 ? `, and the path parameters (${[...pathNames].join(', ')}).` : '.')\n );\n }\n return {\n path,\n query: args.params,\n body: args.body,\n headers: args.headers,\n cookies: args.cookies,\n };\n}\n\n/**\n * The query-serialization hints for the descriptor's query params. A spec is built only\n * when the param deviates from the OpenAPI defaults (`form` + `explode: true`, encoded),\n * and always fully resolved — so `explode: false` or `allowReserved` alone (no `style`)\n * are honored, and an omitted `explode` keeps the exploded default.\n */\nfunction queryStyles(op: OperationDescriptor): Record | undefined {\n let styles: Record | undefined;\n for (const param of op.params ?? []) {\n if (param.in !== 'query') continue;\n const deviates =\n (param.style !== undefined && param.style !== 'form') ||\n param.explode === false ||\n param.allowReserved === true;\n if (!deviates) continue;\n styles ??= {};\n styles[param.name] = {\n style: param.style ?? 'form',\n explode: param.explode ?? true,\n allowReserved: param.allowReserved,\n };\n }\n return styles;\n}\n\n/** Stringify caller-supplied extra headers, skipping empty entries. */\nfunction stringHeaders(headers: Record | undefined): Record {\n const out: Record = {};\n for (const [key, value] of Object.entries(headers ?? {})) {\n if (value !== undefined && value !== null) out[key] = String(value);\n }\n return out;\n}\n\n/** Build the request pieces an attempt needs: the final URL and the merged per-call init. */\nasync function prepareRequest(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions | SseOptions,\n caps: Capabilities\n): Promise<{ url: string; init: RequestOptions; body: unknown }> {\n const { path, query, body, headers, cookies } = splitArgs(op, args);\n const authed: { headers: Record; query: Record } =\n op.security?.length && caps.resolveAuth\n ? await caps.resolveAuth(op.security, config)\n : { headers: {}, query: {} };\n // Cookie params join the auth-injected cookies in one `Cookie` header (values\n // percent-encoded, like auth cookies). Server-side only — browsers own the header.\n const cookiePairs = Object.entries(cookies ?? {})\n .filter(([, value]) => value !== undefined && value !== null)\n .map(([cookieName, value]) => `${cookieName}=${encodeURIComponent(String(value))}`);\n if (cookiePairs.length > 0) {\n authed.headers.Cookie = [authed.headers.Cookie, ...cookiePairs].filter(Boolean).join('; ');\n }\n const fullQuery: Record = { ...query, ...authed.query };\n const url = buildUrl(\n config.serverUrl ?? '',\n substitutePath(op.path, path),\n Object.keys(fullQuery).length > 0 ? fullQuery : undefined,\n queryStyles(op)\n );\n const mergedInit: RequestOptions = {\n ...init,\n method: op.method.toUpperCase(),\n // Precedence, lowest → highest (later spreads win): injected auth → explicit\n // header params → caller `init.headers` — the caller always overrides both.\n headers: {\n ...authed.headers,\n ...stringHeaders(headers),\n ...toHeaderRecord(init.headers),\n },\n };\n return { url, init: mergedInit, body };\n}\n\n/** One non-SSE call: send, then branch on the configured error mode. */\nasync function execute(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions,\n caps: Capabilities\n): Promise {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n const { parseAs, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const { response, context } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (config.errorMode === 'result') {\n if (!response.ok) {\n return { data: undefined, error: await readError(response), response };\n }\n return { data: await parse(response, readKind), error: undefined, response };\n }\n if (!response.ok) {\n let error: globalThis.Error = new ApiError(\n context.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n // Thread the error through each middleware's onError in turn (each may replace it).\n for (const mw of middlewareChain(config)) {\n if (mw.onError) error = await mw.onError(error as ApiErrorLike, context);\n }\n throw error;\n }\n return parse(response, readKind);\n}\n\n/** The paginate capability, or a descriptive throw when a paginated op is iterated unwired. */\nfunction paginateCapability(caps: Capabilities, op: OperationDescriptor) {\n if (!caps.paginate) {\n throw new Error(`Pagination capability not wired: cannot iterate operation \"${op.id}\"`);\n }\n return caps.paginate;\n}\n\n/**\n * The per-page call the iterators drive: the method itself in throw mode; in result\n * mode a wrapper that unwraps the `{ data, error, response }` envelope — the page\n * pointers are data-rooted — rethrowing a failed page as `ApiError` (iteration is\n * error-mode-agnostic; the throw-mode-only `onError` middleware hook is not invoked).\n */\nfunction pageCall(\n method: (args?: OperationArgs, init?: RequestOptions) => Promise,\n config: ClientConfig\n) {\n if (config.errorMode !== 'result') return method;\n return async (args?: OperationArgs, init?: RequestOptions) => {\n const envelope = (await method(args, init)) as {\n data: unknown;\n error: unknown;\n response: Response;\n };\n // Failure is `!response.ok` — NOT `data === undefined`: a successful bodyless page\n // (204/void) also parses to undefined data, and a failed page's `error` can be\n // undefined too (unreadable body). The pointers then miss on the undefined data\n // and iteration stops cleanly, which is the correct semantics for an empty page.\n if (!envelope.response.ok) {\n const { response } = envelope;\n throw new ApiError(response.url, response.status, response.statusText, envelope.error);\n }\n return envelope.data;\n };\n}\n\n/**\n * The per-page call the `link`-style iterators drive: like `execute`, but returning the\n * parsed page together with the raw `Link` header and the page's own URL (for resolving\n * a relative `rel=\"next\"` target). Error-mode-agnostic like all iteration: a failed\n * page throws `ApiError` even on result-mode clients.\n */\nfunction linkPageCall(config: ClientConfig, op: OperationDescriptor, caps: Capabilities) {\n return async (args: OperationArgs = {}, init: RequestOptions = {}) => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const { parseAs, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n const { response } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (!response.ok) {\n throw new ApiError(\n prepared.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n }\n return {\n page: await parse(response, readKind),\n linkHeader: response.headers.get('link'),\n // Some `Response` implementations leave `url` empty (mocks, constructed responses).\n url: response.url === '' ? prepared.url : response.url,\n };\n };\n}\n\n/**\n * Build a typed instance client over operation descriptors: one real bound method per\n * operation (attached by a construction-time loop — no Proxy), plus the core members\n * (`configure`/`use`/`auth`), which are assigned AFTER the loop so they win any name\n * collision with an operation. All behavior dispatches through the capability seam.\n */\nfunction createClientCore<\n Ops extends OpsShape,\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n>(\n operations: Record,\n initial: ClientConfig> = {},\n caps: Capabilities = {}\n): Client> {\n // The literal-union narrowing is a compile-time DX contract only; internally the\n // runtime works with the base (string-typed) context. One cast at this boundary —\n // `ClientConfig` is not assignable to `ClientConfig` (middleware ctx\n // params are contravariant).\n const given = initial as ClientConfig;\n // Private mutable config; the middleware array is copied so `use()` never mutates the caller's.\n const config: ClientConfig = { ...given, middleware: [...(given.middleware ?? [])] };\n const client = {} as Record;\n\n for (const [name, op] of Object.entries(operations)) {\n if (op.responseKind === 'sse') {\n const method = (args: OperationArgs = {}, init: SseOptions = {}) => {\n if (!caps.sse) {\n throw new Error(`SSE capability not wired: cannot stream operation \"${op.id}\"`);\n }\n const stream = caps.sse;\n return (async function* () {\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // A thunk the stream re-runs on every (re)connect, so auth (which `prepareRequest`\n // resolves) is refreshed per attempt rather than frozen at the first connect.\n const prepare = async () => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n return { url: prepared.url, init: prepared.init as SseOptions, body: prepared.body };\n };\n yield* stream(config, opCtx, prepare, op.sseDataKind ?? 'text');\n })();\n };\n // Consumers key off the function reference (cache keys, `OPERATIONS[fn.name]`), so\n // each closure carries its operationId instead of an inferred binding name.\n // `operationId` is the explicit, minification-proof form of the same identity\n // (the SPEC operationId — `name` is the emitted key, which a collision may rename).\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n client[name] = method;\n } else {\n const method = (args: OperationArgs = {}, init: RequestOptions = {}) =>\n execute(config, op, args, init, caps);\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n const spec = op.pagination;\n // Paginated ops keep their one-shot call and gain `.pages`/`.items`, dispatching\n // through the capability seam (like SSE: absent capability throws descriptively).\n // Iteration is error-mode-agnostic: the iterators' pointers are data-rooted, so on\n // a result-mode client (`errorMode` is fixed at construction — `configure()`\n // ignores it) each page's envelope is unwrapped before it reaches the capability.\n // A failed page aborts iteration by throwing ApiError, even on result-mode\n // clients; the `onError` middleware hook (throw-mode-only) is not invoked.\n client[name] =\n spec === undefined\n ? method\n : spec.style === 'link'\n ? Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pagesByLink(\n linkPageCall(config, op, caps),\n args,\n init\n ),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).itemsByLink(\n linkPageCall(config, op, caps),\n spec,\n args,\n init\n ),\n })\n : Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pages(pageCall(method, config), spec, args, init),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).items(pageCall(method, config), spec, args, init),\n });\n }\n }\n\n // Core members are assigned AFTER the operation loop — they win over colliding op names.\n client.configure = (next: ClientConfig): void => {\n // `errorMode` is fixed at generate time (it shapes the static types); flipping it at\n // runtime would silently desync return shapes from `Client`, so it is ignored.\n const { errorMode: _fixed, auth, ...rest } = next;\n Object.assign(config, rest);\n // `auth` merges into existing credentials (like the `auth.*` setters) rather than\n // replacing wholesale — so `configure({ auth: { bearer } })` keeps a previously set\n // basic/apiKey. `apiKey` merges per scheme.\n if (auth) {\n config.auth = {\n ...config.auth,\n ...auth,\n ...(auth.apiKey ? { apiKey: { ...config.auth?.apiKey, ...auth.apiKey } } : {}),\n };\n }\n };\n client.use = (...middleware: Middleware[]): void => {\n // Reassign (don't push) so a caller-provided `middleware` array isn't mutated.\n config.middleware = [...(config.middleware ?? []), ...middleware];\n };\n client.auth = {\n bearer(token: TokenProvider): void {\n config.auth = { ...config.auth, bearer: token };\n },\n basic(username: string, password: string): void {\n config.auth = { ...config.auth, basic: { username, password } };\n },\n apiKey(scheme: string, value: TokenProvider): void {\n config.auth = { ...config.auth, apiKey: { ...config.auth?.apiKey, [scheme]: value } };\n },\n };\n\n return client as Client>;\n}", + 'paginate.ts': + "/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `params` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nfunction resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `params[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nasync function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.params?.[spec.param];\n while (true) {\n const params = { ...args.params };\n if (cursor !== undefined) params[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, params }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `params[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.params?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call(\n { ...args, params: { ...args.params, [spec.param]: position } },\n init\n );\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nasync function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\ntype LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nfunction linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nasync function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let params = args.params;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, params }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n params = { ...args.params, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nasync function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}", + 'cli.ts': + "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\ntype CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\ntype CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. */\n positionals: Array<{ name: string; description?: string }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\ntype CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\ntype CliWiring = {\n binName: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\ntype CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nfunction parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const groups = new Set(commands.filter((c) => c.group).map((c) => c.group as string));\n let command: CliCommand | undefined;\n let rest: string[];\n if (groups.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n command = commands.find((c) => c.group === undefined && c.name === argv[0]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]}` };\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nfunction envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [command.group] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${flag.description ?? ''}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope = typeof topic === 'string' ? commands.filter((c) => c.group === topic) : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n if (seenGroups.has(command.group)) continue;\n seenGroups.add(command.group);\n lines.push(` ${command.group} `);\n continue;\n }\n lines.push(\n ` ${[command.group, command.name].filter(Boolean).join(' ')} ${command.summary ?? ''}`.trimEnd()\n );\n }\n lines.push(\n '',\n `Run ${binName} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nasync function runCli(\n commands: CliCommand[],\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(commands, wiring.binName, invocation.topic)) stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(invocation.command.schemas ?? {}, null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}", +} as const; + export type RuntimeModuleName = keyof typeof RUNTIME_SOURCES; /** Top-level declared names of the runtime modules — precomputed so the pipeline From 60e41252f318d907a7ee377dbcfdb4e24fab36f5 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 13:46:33 +0300 Subject: [PATCH 052/211] =?UTF-8?q?feat(client-generator):=20the=20flip=20?= =?UTF-8?q?=E2=80=94=20sdk=20client=20assembly=20is=20text=20templates=20e?= =?UTF-8?q?nd=20to=20end?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/emitters/__tests__/ts-type.test.ts | 40 +++ .../src/emitters/client-assembly.ts | 295 ++++-------------- .../src/emitters/render-client.ts | 110 ++++++- .../client-generator/src/emitters/ts-type.ts | 2 +- 4 files changed, 203 insertions(+), 244 deletions(-) diff --git a/packages/client-generator/src/emitters/__tests__/ts-type.test.ts b/packages/client-generator/src/emitters/__tests__/ts-type.test.ts index b8cf7c1156..35fcd265b8 100644 --- a/packages/client-generator/src/emitters/__tests__/ts-type.test.ts +++ b/packages/client-generator/src/emitters/__tests__/ts-type.test.ts @@ -89,6 +89,46 @@ const CASES: Array<[string, SchemaModel, DateType?]> = [ ], }, ], + [ + 'multi enum inside a union (parenthesized) — the OAS 3.1 nullable-enum shape', + { + kind: 'union', + members: [ + { kind: 'enum', values: ['active', 'archived'], scalar: 'string' }, + { kind: 'null' }, + ], + }, + ], + [ + 'union inside a union (parenthesized)', + { + kind: 'union', + members: [{ kind: 'union', members: [STRING, { kind: 'null' }] }, INT], + }, + ], + [ + 'intersection inside a union (parenthesized)', + { + kind: 'union', + members: [ + { + kind: 'intersection', + members: [ + { kind: 'ref', name: 'A' }, + { kind: 'ref', name: 'B' }, + ], + }, + { kind: 'null' }, + ], + }, + ], + [ + 'single-value enum inside a union stays bare', + { + kind: 'union', + members: [{ kind: 'enum', values: ['only'], scalar: 'string' }, { kind: 'null' }], + }, + ], ['omit', { kind: 'omit', base: 'Pet', keys: ['id', 'createdAt'] }], ]; diff --git a/packages/client-generator/src/emitters/client-assembly.ts b/packages/client-generator/src/emitters/client-assembly.ts index 41859cf70e..4582364a30 100644 --- a/packages/client-generator/src/emitters/client-assembly.ts +++ b/packages/client-generator/src/emitters/client-assembly.ts @@ -1,6 +1,6 @@ // Client assembly, shared by both runtime distributions and both output modes. The -// wiring (descriptor map + `Ops` interface, emitters/descriptor.ts) is identical; only -// the runtime block differs — `runtime: 'package'` imports `createClient` from +// wiring (descriptor map + `Ops` interface) is identical; only the runtime block +// differs — `runtime: 'package'` imports `createClient` from // `@redocly/client-generator`, everything else (inline, the default) embeds the // assembled runtime sources in its place (emitters/inline-runtime.ts). Single-file // layout: runtime (import line | embedded block) → schema types → type guards → @@ -8,6 +8,7 @@ // (package mode only) type re-exports — the embedded types are already exported in // place, so the embed arm needs none. Split mode moves the schema types + guards into // a sibling `.schemas.ts` the entry re-exports (`emitClientSplit`). +// Text templates throughout — no `typescript` at generate time. import { allOperations, @@ -16,31 +17,22 @@ import { type SecuritySchemeModel, } from '../intermediate-representation/model.js'; import { apiKeySetterName } from './auth.js'; -import { descriptorStatements, opsInterfaceStatements, packageIdents } from './descriptor.js'; +import { packageIdents, renderDescriptors } from './descriptor.js'; import { banner, type EmitOptions, HEADER, renderTitleComment } from './emit-options.js'; -import { codeString, isIdentifier } from './identifier.js'; +import { codeString } from './identifier.js'; import { assembleInlineRuntime } from './inline-runtime.js'; -import { renderOperationAliases, sseAliases } from './operation-aliases.js'; -import { operationSignature } from './operation-signature.js'; -import { computeResponse, errorTypeNodes, isTypedMultipart } from './operation-types.js'; -import { type EmitContext, renderArgList } from './operations.js'; +import { isTypedMultipart } from './operation-types.js'; +import type { EmitContext } from './operations.js'; import { resolveModelPagination } from './pagination.js'; -import { responseHeadersTypeLiteral } from './response-headers.js'; -import { isSseOp } from './sse.js'; -import { pascalCase } from './support.js'; import { - arrow, - exportConstStatement, - parseStatements, - printNodes, - printStatements, - ts, - typedArrow, -} from './ts.js'; -import { typeGuardStatements } from './type-guards.js'; -import { typesStatements } from './types.js'; - -const { factory } = ts; + collectEntrySchemaRefs, + renderAliases, + renderFlatSugar, + renderOpsType, +} from './render-client.js'; +import { isSseOp } from './sse.js'; +import { renderTypeAliases } from './ts-type.js'; +import { renderTypeGuards } from './type-guards.js'; const PACKAGE_SPECIFIER = '@redocly/client-generator'; @@ -80,7 +72,6 @@ function emitClient( errorMode: options.errorMode ?? 'throw', dateType: options.dateType ?? 'string', schemaNames: new Set(model.schemas.map((s) => s.name)), - schemas: model.schemas, pagination, }; const flat = ctx.argsStyle === 'flat'; @@ -93,14 +84,14 @@ function emitClient( const wiring = ops.length > 0 ? [ - ...opsInterfaceStatements(model, idents, ctx), - ...descriptorStatements(model, idents, ctx.dateType, pagination), + renderOpsType(model, idents, ctx), + renderDescriptors(model, idents, ctx.dateType, pagination), ] : // A spec with no operations still gets the uniform wiring shape. - parseStatements( - 'export type Ops = Record;\n' + - 'export const OPERATIONS = {} as const satisfies Record;' - ); + [ + 'export type Ops = Record;', + 'export const OPERATIONS = {} as const satisfies Record;', + ]; const runtimeSection = embed ? assembleInlineRuntime({ @@ -118,12 +109,16 @@ function emitClient( hasRegular, hasApiKey: apiKeySchemes.length > 0, }); - const schemaStatements = [ - ...typesStatements(model.schemas, ctx.dateType), - ...typeGuardStatements(model.schemas), - ]; - const bodyStatements = [...ops.flatMap((op) => aliasStatements(op, ctx)), ...wiring]; - const sugar = printNodes(sugarStatements(ops, idents, ctx, model.securitySchemes, apiKeySchemes)); + const schemaSection = [ + renderTypeAliases(model.schemas, ctx.dateType), + renderTypeGuards(model.schemas), + ] + .filter((section) => section.length > 0) + .join('\n\n'); + const bodySection = [...ops.map((op) => renderAliases(op, ctx, 'wire')), ...wiring] + .filter((section) => section.length > 0) + .join('\n\n'); + const sugar = sugarSection(ops, idents, ctx, model.securitySchemes, apiKeySchemes); // Embed mode exports its whole public surface in place; only the package arm re-exports. const reexports = embed ? '' : reexportLines(ctx, hasSse); @@ -138,7 +133,7 @@ function emitClient( HEADER, renderTitleComment(model), ...(embed ? [] : [runtimeSection]), - printStatements([...schemaStatements, ...bodyStatements]), + [schemaSection, bodySection].filter((section) => section.length > 0).join('\n\n'), ...(embed ? [runtimeSection] : []), clientSection(options, ctx, model), sugar, @@ -147,53 +142,32 @@ function emitClient( }; } - const body = printStatements(bodyStatements); - const hasSchemas = schemaStatements.length > 0; + const hasSchemas = schemaSection.length > 0; return { entry: banner([ HEADER, renderTitleComment(model), hasSchemas - ? schemaLinks( - body + '\n' + sugar, - ctx.schemaNames, - `./${splitStem}.schemas.${options.importExt ?? 'js'}` - ) + ? schemaLinks(model, ctx, `./${splitStem}.schemas.${options.importExt ?? 'js'}`) : '', ...(embed ? [] : [runtimeSection]), - body, + bodySection, ...(embed ? [runtimeSection] : []), clientSection(options, ctx, model), sugar, reexports, ]), - schemas: hasSchemas - ? banner([HEADER, renderTitleComment(model), printStatements(schemaStatements)]) - : undefined, + schemas: hasSchemas ? banner([HEADER, renderTitleComment(model), schemaSection]) : undefined, }; } /** * The entry ⇄ schemas linkage of the split layout: a type-only import of exactly the - * schema names the entry's own code references, plus the public `export *` re-export. - * Referenced names are found by walking the printed entry code's identifiers (an AST - * pass over the emitted text, not a substring search — operation JSDoc may mention a - * schema name, and importing an unreferenced type would trip `noUnusedLocals`). + * schema names the entry's own code references (derived from the IR — the same + * sources the alias/Ops renderers type), plus the public `export *` re-export. */ -function schemaLinks(entryCode: string, schemaNames: Set, specifier: string): string { - const referenced = new Set(); - // Only TYPE references count as uses of the type-only import: a value-position - // identifier that happens to share a schema's name (every descriptor's `id:` key, - // for a schema named `id`) must not drag the name in — strict consumer lint - // configs flag the resulting unused import. - const visit = (node: ts.Node): void => { - if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName)) { - if (schemaNames.has(node.typeName.text)) referenced.add(node.typeName.text); - } - node.forEachChild(visit); - }; - for (const statement of parseStatements(entryCode)) visit(statement); - const names = [...referenced].sort(); +function schemaLinks(model: ApiModel, ctx: EmitContext, specifier: string): string { + const names = collectEntrySchemaRefs(model, ctx); const importLine = names.length > 0 ? `import type { ${names.join(', ')} } from '${specifier}';\n` : ''; return `${importLine}export * from '${specifier}';`; @@ -211,8 +185,6 @@ function importLine( 'OperationDescriptor', // Flat sugar signatures reference the per-call option types. ...(refs.hasFlatRegular ? ['RequestOptions'] : []), - // Flat throw-mode sugar return types vary with the inferred request-option type. - ...(refs.hasFlatRegular && ctx.errorMode !== 'result' ? ['EnvelopeResult'] : []), // `Ops` wraps results in `Result` in result mode — but only NON-SSE members // (an SSE-only spec would otherwise import it unused and fail noUnusedLocals). ...(ctx.errorMode === 'result' && refs.hasRegular ? ['Result'] : []), @@ -224,29 +196,6 @@ function importLine( return `import { ${names} } from '${PACKAGE_SPECIFIER}';`; } -/** One operation's `*` aliases — the same emitters and suppression rules as inline mode. */ -function aliasStatements(op: OperationModel, ctx: EmitContext): ts.Statement[] { - const { pathParams } = operationSignature(op); - const ordered = pathParams.map((p) => p.param); - const identMap = new Map(pathParams.map((p) => [p.param.name, p.ident])); - if (isSseOp(op)) return sseAliases(op, ordered, identMap, ctx, 'wire'); - const { responseType } = computeResponse(op.successResponses, ctx.dateType); - const errorMembers = - ctx.errorMode === 'result' ? errorTypeNodes(op.errorResponses, ctx.dateType) : []; - const errorAlias = errorMembers.length > 0 ? `${pascalCase(op.name)}Error` : ''; - return renderOperationAliases( - op, - responseType, - ordered, - identMap, - errorAlias, - errorMembers, - ctx, - true, - 'wire' - ); -} - /** The (optional) baked setup + the default `client` instance. */ function clientSection(options: EmitOptions, ctx: EmitContext, model: ApiModel): string { const serverUrl = options.serverUrl ?? model.serverUrl; @@ -267,7 +216,7 @@ function clientSection(options: EmitOptions, ctx: EmitContext, model: ApiModel): ? `mergeSetup({ config: ${config} }, mergeSetup(__redoclySetup, {}))` : config; // The trailing type args narrow `ctx.operation` to the spec's literal unions. - // `OperationTag` mirrors descriptorStatements' gate: derived only when some + // `OperationTag` mirrors the descriptor block's gate: derived only when some // operation is tagged (it would otherwise be `never`); zero-ops specs have no // derived unions at all, so they keep the string defaults. const ops = allOperations(model.services); @@ -286,182 +235,44 @@ function clientSection(options: EmitOptions, ctx: EmitContext, model: ApiModel): } /** Core destructure + per-scheme auth setters + per-operation call sugar. */ -function sugarStatements( +function sugarSection( ops: OperationModel[], idents: Map, ctx: EmitContext, schemes: SecuritySchemeModel[], apiKeySchemes: SecuritySchemeModel[] -): ts.Statement[] { - const statements = [...parseStatements('export const { configure, use } = client;')]; +): string { + const lines = ['export const { configure, use } = client;']; // Auth sugar in `authSetterNames` order: bearer, basic, then each apiKey scheme. // The runtime's auth members close over the instance config (no `this`), so // direct bindings are safe. if (schemes.some((s) => s.kind === 'bearer')) { - statements.push(...parseStatements('export const setBearer = client.auth.bearer;')); + lines.push('export const setBearer = client.auth.bearer;'); } if (schemes.some((s) => s.kind === 'basic')) { - statements.push(...parseStatements('export const setBasicAuth = client.auth.basic;')); + lines.push('export const setBasicAuth = client.auth.basic;'); } for (const scheme of apiKeySchemes) { const name = apiKeySetterName(scheme.key, apiKeySchemes.length === 1); - statements.push( - ...parseStatements( - `export const ${name} = (value: TokenProvider) => client.auth.apiKey(${codeString(scheme.key)}, value);` - ) + lines.push( + `export const ${name} = (value: TokenProvider) => client.auth.apiKey(${codeString(scheme.key)}, value);` ); } - if (ops.length === 0) return statements; + if (ops.length === 0) return lines.join('\n'); if (ctx.argsStyle === 'grouped') { // Grouped style: the client methods already take the grouped args shape. const names = ops.map((op) => idents.get(op.name)!).join(', '); - statements.push(...parseStatements(`export const { ${names} } = client;`)); - return statements; - } - for (const op of ops) statements.push(flatSugarStatement(op, idents.get(op.name)!, ctx)); - return statements; -} - -/** - * One flat one-liner: today's positional signature forwarding to the grouped client - * method. Path values are keyed by the WIRE name (the runtime routes - * `args[param.name]`); a path param literally named `params`/`body`/`headers` would - * collide with the slot keys — a spec-acknowledged runtime-contract limitation. - * A paginated operation's arrow is wrapped in `Object.assign(…, { pages, items })` - * so the flat sugar preserves the method-attached iterators. - * Throw-mode (non-SSE) arrows are generic over `init` so `{ envelope: true }` narrows - * the return type to `Envelope<…>` (plain `RequestOptions` would collapse the overload). - */ -function flatSugarStatement(op: OperationModel, ident: string, ctx: EmitContext): ts.Statement { - const { pathParams } = operationSignature(op); - const params = renderArgList( - op, - pathParams.map((p) => p.param), - new Map(pathParams.map((p) => [p.param.name, p.ident])), - ctx - ); - const props: ts.ObjectLiteralElementLike[] = pathParams.map(({ param, ident: paramIdent }) => - param.name === paramIdent - ? factory.createShorthandPropertyAssignment(paramIdent) - : factory.createPropertyAssignment( - isIdentifier(param.name) ? param.name : factory.createStringLiteral(param.name), - factory.createIdentifier(paramIdent) - ) - ); - if (op.queryParams.length > 0) props.push(factory.createShorthandPropertyAssignment('params')); - if (op.requestBody) props.push(factory.createShorthandPropertyAssignment('body')); - if (op.headerParams.length > 0) { - props.push(factory.createShorthandPropertyAssignment('headers')); - } - if (op.cookieParams.length > 0) { - props.push(factory.createShorthandPropertyAssignment('cookies')); - } - const call = factory.createCallExpression( - factory.createPropertyAccessExpression(factory.createIdentifier('client'), ident), - undefined, - [factory.createObjectLiteralExpression(props, false), factory.createIdentifier('init')] - ); - const fn = - ctx.errorMode !== 'result' && !isSseOp(op) - ? envelopeAwareFlatArrow(op, params, call, ctx) - : arrow(params, call); - if (!ctx.pagination?.has(op.name)) return exportConstStatement(ident, fn); - const methodMember = (name: string) => - factory.createPropertyAssignment( - name, - factory.createPropertyAccessExpression( - factory.createPropertyAccessExpression(factory.createIdentifier('client'), ident), - name - ) - ); - return exportConstStatement( - ident, - factory.createCallExpression( - factory.createPropertyAccessExpression(factory.createIdentifier('Object'), 'assign'), - undefined, - [ - fn, - factory.createObjectLiteralExpression( - [methodMember('pages'), methodMember('items')], - false - ), - ] - ) - ); -} - -/** - * `(…, init?: I) => - * Promise>` - */ -function envelopeAwareFlatArrow( - op: OperationModel, - params: ts.ParameterDeclaration[], - call: ts.Expression, - ctx: EmitContext -): ts.ArrowFunction { - // `renderArgList` always appends `init` last — retype it as optional generic `I` - // (no default: `init?: I = {}` is invalid, and `init: I = {}` fails under strict - // generic checks). Cast the call's Promise so the conditional return type sticks. - const initTyped = [ - ...params.slice(0, -1), - factory.createParameterDeclaration( - undefined, - undefined, - 'init', - factory.createToken(ts.SyntaxKind.QuestionToken), - factory.createTypeReferenceNode('I') - ), - ]; - const resultType = flatResultType(op, ctx); - const headersType = flatHeadersType(op, ctx); - const returnType = factory.createTypeReferenceNode('Promise', [ - factory.createTypeReferenceNode('EnvelopeResult', [ - resultType, - headersType, - factory.createTypeReferenceNode('I'), - ]), - ]); - const typeParam = factory.createTypeParameterDeclaration( - undefined, - 'I', - factory.createUnionTypeNode([ - factory.createTypeReferenceNode('RequestOptions'), - factory.createKeywordTypeNode(ts.SyntaxKind.UndefinedKeyword), - ]), - factory.createKeywordTypeNode(ts.SyntaxKind.UndefinedKeyword) - ); - const castCall = factory.createAsExpression(call, returnType); - return typedArrow([typeParam], initTyped, returnType, castCall); -} - -function flatResultType(op: OperationModel, ctx: EmitContext): ts.TypeNode { - const { responseType } = computeResponse(op.successResponses, ctx.dateType); - const resultName = `${pascalCase(op.name)}Result`; - return ctx.schemaNames.has(resultName) - ? responseType - : factory.createTypeReferenceNode(resultName); -} - -function flatHeadersType(op: OperationModel, ctx: EmitContext): ts.TypeNode { - const headers = op.successResponseHeaders; - if (!headers || headers.length === 0) { - return factory.createTypeReferenceNode('Record', [ - factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - factory.createKeywordTypeNode(ts.SyntaxKind.NeverKeyword), - ]); + lines.push(`export const { ${names} } = client;`); + return lines.join('\n'); } - const alias = `${pascalCase(op.name)}ResponseHeaders`; - return ctx.schemaNames.has(alias) - ? responseHeadersTypeLiteral(headers, ctx.schemas) - : factory.createTypeReferenceNode(alias); + for (const op of ops) lines.push(renderFlatSugar(op, idents.get(op.name)!, ctx)); + 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'] : []), diff --git a/packages/client-generator/src/emitters/render-client.ts b/packages/client-generator/src/emitters/render-client.ts index edbf73097f..ad4edaebef 100644 --- a/packages/client-generator/src/emitters/render-client.ts +++ b/packages/client-generator/src/emitters/render-client.ts @@ -12,8 +12,9 @@ import { type ParamModel, type RequestBodyModel, type ResponseBodyModel, + type SchemaModel, } from '../intermediate-representation/model.js'; -import { safeIdent } from './identifier.js'; +import { isIdentifier, safeIdent } from './identifier.js'; import { operationSignature } from './operation-signature.js'; import { isTypedMultipart } from './operation-types.js'; import type { EmitContext } from './operations.js'; @@ -311,3 +312,110 @@ export function renderAliases( } return blocks.join('\n\n'); } + +/** The flat sugar's parameter list (path args, slots, trailing `init`), as text. */ +function argListText( + op: OperationModel, + orderedPathParams: ParamModel[], + pathParamIdent: Map, + ctx: EmitContext +): string { + const { dateType } = ctx; + const args: string[] = orderedPathParams.map( + (param) => `${pathParamIdent.get(param.name)!}: ${tsType(param.schema, dateType)}` + ); + const slot = (name: string, params: ParamModel[]) => + `${name}: ${paramsTypeText(params, dateType)}${params.some((p) => p.required) ? '' : ' = {}'}`; + if (op.queryParams.length > 0) args.push(slot('params', op.queryParams)); + if (op.requestBody) { + args.push( + `body${op.requestBody.required ? '' : '?'}: ${bodyTypeText(op.requestBody, dateType)}` + ); + } + if (op.headerParams.length > 0) args.push(slot('headers', op.headerParams)); + if (op.cookieParams.length > 0) args.push(slot('cookies', op.cookieParams)); + args.push(`init: ${isSseOp(op) ? 'SseOptions' : 'RequestOptions'} = {}`); + return args.join(', '); +} + +/** One flat one-liner: the positional signature forwarding to the grouped client method. */ +export function renderFlatSugar(op: OperationModel, ident: string, ctx: EmitContext): string { + const { pathParams } = operationSignature(op); + const params = argListText( + op, + pathParams.map((p) => p.param), + new Map(pathParams.map((p) => [p.param.name, p.ident])), + ctx + ); + const props: string[] = pathParams.map(({ param, ident: paramIdent }) => + param.name === paramIdent + ? paramIdent + : `${isIdentifier(param.name) ? param.name : JSON.stringify(param.name)}: ${paramIdent}` + ); + if (op.queryParams.length > 0) props.push('params'); + if (op.requestBody) props.push('body'); + if (op.headerParams.length > 0) props.push('headers'); + if (op.cookieParams.length > 0) props.push('cookies'); + const args = props.length === 0 ? '{}' : `{ ${props.join(', ')} }`; + const fn = `(${params}) => client.${ident}(${args}, init)`; + if (!ctx.pagination?.has(op.name)) return `export const ${ident} = ${fn};`; + return `export const ${ident} = Object.assign(${fn}, { pages: client.${ident}.pages, items: client.${ident}.items });`; +} + +/** + * Schema names the ENTRY file's own types reference — the split layout's type-only + * import list. Derived from the IR (the exact sources the alias/Ops renderers type): + * every ref reachable from operation inputs, success responses, error responses + * (result mode only — throw mode never renders them), and pagination item schemas. + * Named schema BODIES are not expanded: a ref renders as its bare name. + */ +export function collectEntrySchemaRefs(model: ApiModel, ctx: EmitContext): string[] { + const referenced = new Set(); + const walk = (schema: SchemaModel): void => { + switch (schema.kind) { + case 'ref': + referenced.add(schema.name); + return; + case 'omit': + referenced.add(schema.base); + return; + case 'array': + walk(schema.items); + return; + case 'record': + walk(schema.value); + return; + case 'object': + for (const property of schema.properties) walk(property.schema); + return; + case 'union': + case 'intersection': + for (const member of schema.members) walk(member); + return; + default: + return; + } + }; + for (const op of allOperations(model.services)) { + for (const param of [ + ...op.pathParams, + ...op.queryParams, + ...op.headerParams, + ...op.cookieParams, + ]) { + walk(param.schema); + } + if (op.requestBody) walk(op.requestBody.schema); + for (const response of op.successResponses) { + walk(response.schema); + // SSE responses type their event payload from the stream's item schema. + if (response.itemSchema) walk(response.itemSchema); + } + if (ctx.errorMode === 'result') { + for (const response of op.errorResponses) walk(response.schema); + } + const paginated = ctx.pagination?.get(op.name); + if (paginated) walk(paginated.itemSchema); + } + return [...referenced].filter((name) => ctx.schemaNames.has(name)).sort(); +} diff --git a/packages/client-generator/src/emitters/ts-type.ts b/packages/client-generator/src/emitters/ts-type.ts index b11fb95f1f..6d1461e312 100644 --- a/packages/client-generator/src/emitters/ts-type.ts +++ b/packages/client-generator/src/emitters/ts-type.ts @@ -104,7 +104,7 @@ export function tsType(schema: SchemaModel, dateType: DateType = 'string', inden return schema.members .map((member) => { const rendered = tsType(member, dateType, indent); - return member.kind === 'intersection' ? `(${rendered})` : rendered; + return isCompound(member) ? `(${rendered})` : rendered; }) .join(' | '); case 'intersection': From b2869a81d874ef342bcc83122798732760f1172e Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 14:03:16 +0300 Subject: [PATCH 053/211] =?UTF-8?q?feat(client-generator):=20tanstack,=20s?= =?UTF-8?q?wr,=20and=20wrapper=20support=20on=20text=20templates=20?= =?UTF-8?q?=E2=80=94=20byte-identical?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/emitters/emit-options.ts | 2 +- packages/client-generator/src/emitters/swr.ts | 158 ++++-------------- .../src/emitters/tanstack-query.ts | 6 +- .../src/emitters/wrapper-support.ts | 107 +++--------- 4 files changed, 58 insertions(+), 215 deletions(-) diff --git a/packages/client-generator/src/emitters/emit-options.ts b/packages/client-generator/src/emitters/emit-options.ts index e425ae1d20..13c55f4022 100644 --- a/packages/client-generator/src/emitters/emit-options.ts +++ b/packages/client-generator/src/emitters/emit-options.ts @@ -1,8 +1,8 @@ import type { ApiModel } from '../intermediate-representation/model.js'; +import { escapeJsDoc } from './jsdoc.js'; import type { ArgsStyle } from './operations.js'; import type { PaginationConfig } from './pagination.js'; import { splitLines } from './support.js'; -import { escapeJsDoc } from './ts.js'; import type { DateType } from './types.js'; // The public option vocabulary is re-exported from this module, so generators diff --git a/packages/client-generator/src/emitters/swr.ts b/packages/client-generator/src/emitters/swr.ts index d9d0901437..da48c719b1 100644 --- a/packages/client-generator/src/emitters/swr.ts +++ b/packages/client-generator/src/emitters/swr.ts @@ -6,30 +6,19 @@ // or flat `(vars.petId, …, init)`) via the shared `operationSignature`, so the // call type-checks against the generated sdk. // `swr`/`swr/mutation` are the consumer's peer; the sdk stays dependency-free. -// AST-native via `ts.factory`. +// Source-text templates throughout. import type { ApiModel, OperationModel } from '../intermediate-representation/model.js'; import { pascalCase } from './support.js'; -import { - arrow, - constArray, - exportConstStatement as exportConst, - printStatements, - ts, -} from './ts.js'; import { hasInputs, - initParam, isQuery, - sdkCall, - sdkNamedImport, + sdkCallText, + sdkNamedImportText, variablesName, - varsParam, wrappableOperations, } from './wrapper-support.js'; -const { factory } = ts; - export type SwrOptions = { /** Import specifier for the sdk entry the operation functions/types live in. */ sdkModule: string; @@ -41,126 +30,45 @@ export type SwrOptions = { export function renderSwrModule(model: ApiModel, opts: SwrOptions): string { const ops = wrappableOperations(model, 'swr'); if (ops.length === 0) return ''; - return printStatements(swrStatements(ops, opts)); -} - -/** The SWR module statements: the import header followed by per-op hooks. */ -function swrStatements(ops: OperationModel[], opts: SwrOptions): ts.Statement[] { const hasQuery = ops.some(isQuery); const hasMutation = ops.some((op) => !isQuery(op)); - const statements: ts.Statement[] = []; - for (const op of ops) { - statements.push(...(isQuery(op) ? queryStatements(op, opts) : [mutationStatement(op, opts)])); - } - return [...importHeader(ops, opts, hasQuery, hasMutation), ...statements]; + const blocks = [ + ...(hasQuery ? ['import useSWR from "swr";'] : []), + ...(hasMutation ? ['import useSWRMutation from "swr/mutation";'] : []), + sdkNamedImportText(ops, opts.sdkModule, hasQuery), + ...ops.flatMap((op) => (isQuery(op) ? queryBlocks(op, opts) : [mutationBlock(op, opts)])), + ]; + return blocks.join('\n\n'); } -/** An exported `function use() { }` declaration. */ -function exportHook( - op: OperationModel, - params: ts.ParameterDeclaration[], - ret: ts.Expression -): ts.Statement { - const name = `use${pascalCase(op.name)}`; - return factory.createFunctionDeclaration( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - undefined, - name, - undefined, - params, - undefined, - factory.createBlock([factory.createReturnStatement(ret)], true) - ); +/** An exported `function use() { return ; }` declaration. */ +function hookBlock(op: OperationModel, params: string, expr: string): string { + return `export function use${pascalCase(op.name)}(${params}) {\n return ${expr};\n}`; } /** A query op's `Key` factory + `use` hook calling `useSWR`. */ -function queryStatements(op: OperationModel, opts: SwrOptions): ts.Statement[] { +function queryBlocks(op: OperationModel, opts: SwrOptions): string[] { const inputs = hasInputs(op); - const keyId = factory.createStringLiteral(op.name); - const keyParams = inputs ? [varsParam(op)] : []; - const keyElements = inputs ? [keyId, factory.createIdentifier('vars')] : [keyId]; - const key = exportConst(`${op.name}Key`, arrow(keyParams, constArray(keyElements))); - - const keyCall = factory.createCallExpression( - factory.createIdentifier(`${op.name}Key`), - undefined, - inputs ? [factory.createIdentifier('vars')] : [] - ); - const useSwr = factory.createCallExpression(factory.createIdentifier('useSWR'), undefined, [ - keyCall, - arrow([], sdkCall(op, opts.argsStyle, 'vars', true)), - ]); - - const params = inputs ? [varsParam(op), initParam()] : [initParam()]; - return [key, exportHook(op, params, useSwr)]; + const keyParams = inputs ? `vars: ${variablesName(op)}` : ''; + const keyElements = inputs + ? `[${JSON.stringify(op.name)}, vars]` + : `[${JSON.stringify(op.name)}]`; + const key = `export const ${op.name}Key = (${keyParams}) => ${keyElements} as const;`; + const keyCall = `${op.name}Key(${inputs ? 'vars' : ''})`; + const useSwr = `useSWR(${keyCall}, () => ${sdkCallText(op, opts.argsStyle, 'vars', true)})`; + const params = inputs + ? `vars: ${variablesName(op)}, init?: RequestOptions` + : 'init?: RequestOptions'; + return [key, hookBlock(op, params, useSwr)]; } /** A mutation op's `use` hook calling `useSWRMutation`. */ -function mutationStatement(op: OperationModel, opts: SwrOptions): ts.Statement { - const inputs = hasInputs(op); - const key = factory.createStringLiteral(op.name); - - // `(_key: string, { arg }: { arg: Variables }) => (…arg)` when the op has inputs; - // a no-arg `() => ()` when it has none (`arg` would be unused). - const trigger = inputs - ? triggerWithArg(op, opts) - : arrow([], sdkCall(op, opts.argsStyle, 'arg', false)); - const useSwrMutation = factory.createCallExpression( - factory.createIdentifier('useSWRMutation'), - undefined, - [key, trigger] - ); - return exportHook(op, [], useSwrMutation); -} - -/** `(_key: string, { arg }: { arg: Variables }) => (…arg)`. */ -function triggerWithArg(op: OperationModel, opts: SwrOptions): ts.ArrowFunction { - const keyParam = factory.createParameterDeclaration( - undefined, - undefined, - '_key', - undefined, - factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword) - ); - const argParam = factory.createParameterDeclaration( - undefined, - undefined, - factory.createObjectBindingPattern([factory.createBindingElement(undefined, undefined, 'arg')]), - undefined, - factory.createTypeLiteralNode([ - factory.createPropertySignature( - undefined, - 'arg', - undefined, - factory.createTypeReferenceNode(variablesName(op)) - ), - ]) - ); - return arrow([keyParam, argParam], sdkCall(op, opts.argsStyle, 'arg', false)); -} - -/** - * The import header: `useSWR` from `swr` (when any query op), `useSWRMutation` from - * `swr/mutation` (when any mutation op), then the shared sdk named import. - */ -function importHeader( - ops: OperationModel[], - opts: SwrOptions, - hasQuery: boolean, - hasMutation: boolean -): ts.Statement[] { - const imports: ts.Statement[] = []; - if (hasQuery) imports.push(defaultImport('useSWR', 'swr')); - if (hasMutation) imports.push(defaultImport('useSWRMutation', 'swr/mutation')); - imports.push(sdkNamedImport(ops, opts.sdkModule, hasQuery)); - return imports; -} - -/** `import from "";` (default import). */ -function defaultImport(name: string, module: string): ts.Statement { - return factory.createImportDeclaration( - undefined, - factory.createImportClause(false, factory.createIdentifier(name), undefined), - factory.createStringLiteral(module) - ); +function mutationBlock(op: OperationModel, opts: SwrOptions): string { + // `(_key: string, { arg }: { arg: Variables }) => (…arg)` when the op has + // inputs; a no-arg `() => ()` when it has none (`arg` would be unused). + const trigger = hasInputs(op) + ? `(_key: string, { arg }: {\n arg: ${variablesName(op)};\n }) => ${sdkCallText(op, opts.argsStyle, 'arg', false)}` + : `() => ${sdkCallText(op, opts.argsStyle, 'arg', false)}`; + const useSwrMutation = `useSWRMutation(${JSON.stringify(op.name)}, ${trigger})`; + return hookBlock(op, '', useSwrMutation); } diff --git a/packages/client-generator/src/emitters/tanstack-query.ts b/packages/client-generator/src/emitters/tanstack-query.ts index d285c0bde4..836e3bbaf2 100644 --- a/packages/client-generator/src/emitters/tanstack-query.ts +++ b/packages/client-generator/src/emitters/tanstack-query.ts @@ -9,8 +9,7 @@ // through the client instance's grouped methods, so the module is independent of the // sdk's `--args-style`. // -// The factory bodies are authored as source text and round-tripped through -// `parseStatements` → `printStatements`, which validates the syntax at generation time +// The factory bodies are authored as source text — the emitted module verbatim // and normalizes everything to the printer's canonical style. Every interpolated piece // is generator-derived (sanitized operation names, JSON-pointer property chains built // here) — never raw spec text. @@ -24,7 +23,6 @@ import { resolveModelPagination, resolveSchemaPointer, } from './pagination.js'; -import { parseStatements, printStatements } from './ts.js'; import { hasInputs, isQuery, variablesName, wrappableOperations } from './wrapper-support.js'; export type TanstackOptions = { @@ -50,7 +48,7 @@ export function renderTanstackModule(model: ApiModel, opts: TanstackOptions): st factoriesSource(model, ops, pagination, opts.queryKeyPrefix), ...defaultBindings(ops, pagination), ].join('\n'); - return printStatements(parseStatements(source)); + return source; } /** diff --git a/packages/client-generator/src/emitters/wrapper-support.ts b/packages/client-generator/src/emitters/wrapper-support.ts index 07971d8dbc..3da14cfc4c 100644 --- a/packages/client-generator/src/emitters/wrapper-support.ts +++ b/packages/client-generator/src/emitters/wrapper-support.ts @@ -10,9 +10,6 @@ import { logger } from '@redocly/openapi-core'; import type { ApiModel, OperationModel } from '../intermediate-representation/model.js'; import { operationSignature } from './operation-signature.js'; import { isSseOp } from './sse.js'; -import { ts } from './ts.js'; - -const { factory } = ts; /** * The operations a wrapper generator can wrap, with skips reported to the user under @@ -68,102 +65,42 @@ export function variablesName(op: OperationModel): string { return operationSignature(op).variablesTypeName; } -/** A `vars: Variables` parameter. */ -export function varsParam(op: OperationModel): ts.ParameterDeclaration { - return factory.createParameterDeclaration( - undefined, - undefined, - 'vars', - undefined, - factory.createTypeReferenceNode(variablesName(op)) - ); -} - -/** - * An `init?: Omit` parameter. The wrappers cache the - * fetched body, so the throw-only `envelope` option is excluded from the type and - * stripped at runtime by `sdkCall`. - */ -export function initParam(): ts.ParameterDeclaration { - return factory.createParameterDeclaration( - undefined, - undefined, - 'init', - factory.createToken(ts.SyntaxKind.QuestionToken), - factory.createTypeReferenceNode('Omit', [ - factory.createTypeReferenceNode('RequestOptions'), - factory.createLiteralTypeNode(factory.createStringLiteral('envelope')), - ]) - ); -} - -/** - * The forwarding call to the sdk operation function; argument order comes from the - * shared `operationSignature`. `grouped` passes the source object — `{}` for a - * no-input op with an init, which must not land in the `(args?, init?)` args slot; - * `flat` spreads `.`, then `.params` / `.body` / `.headers`. - * `withInit` appends `{ ...init, envelope: undefined }` — a runtime strip, since - * `initParam`'s `Omit` is type-only. - */ -export function sdkCall( +/** The forwarding-call ARGUMENT LIST to the sdk operation function, as text. Argument + * order comes from the shared `operationSignature`, so it lines up with the sdk's + * parameter list by construction. `grouped` passes the source object (when inputs); + * `flat` spreads `.` (URL-template order), then the slots the op + * has. `init` is appended last when `withInit`. */ +export function sdkCallText( op: OperationModel, argsStyle: 'flat' | 'grouped', source: string, withInit: boolean -): ts.Expression { +): string { const sig = operationSignature(op); - const sourceIdent = factory.createIdentifier(source); - const args: ts.Expression[] = []; - + const args: string[] = []; if (argsStyle === 'grouped') { - if (sig.hasInputs) args.push(sourceIdent); - else if (withInit) args.push(factory.createObjectLiteralExpression([])); + if (sig.hasInputs) args.push(source); } else { - for (const { ident } of sig.pathParams) { - args.push(factory.createPropertyAccessExpression(sourceIdent, ident)); - } - if (sig.hasQuery) args.push(factory.createPropertyAccessExpression(sourceIdent, 'params')); - if (sig.hasBody) args.push(factory.createPropertyAccessExpression(sourceIdent, 'body')); - if (sig.hasHeaders) args.push(factory.createPropertyAccessExpression(sourceIdent, 'headers')); - if (sig.hasCookies) args.push(factory.createPropertyAccessExpression(sourceIdent, 'cookies')); - } - if (withInit) { - args.push( - factory.createObjectLiteralExpression([ - factory.createSpreadAssignment(factory.createIdentifier('init')), - factory.createPropertyAssignment('envelope', factory.createIdentifier('undefined')), - ]) - ); + for (const { ident } of sig.pathParams) args.push(`${source}.${ident}`); + if (sig.hasQuery) args.push(`${source}.params`); + if (sig.hasBody) args.push(`${source}.body`); + if (sig.hasHeaders) args.push(`${source}.headers`); + if (sig.hasCookies) args.push(`${source}.cookies`); } - - return factory.createCallExpression(factory.createIdentifier(op.name), undefined, args); + if (withInit) args.push('init'); + return `${op.name}(${args.join(', ')})`; } -/** - * The named import from the sdk module: the wrapped opFns as value specifiers, then - * the referenced `Variables` types + `RequestOptions` (when any query op) as - * `type` specifiers, each group sorted. - */ -export function sdkNamedImport( +/** The named import from the sdk module: wrapped opFns, then the referenced + * `Variables` types + `RequestOptions` (when any query op) as `type` specifiers. */ +export function sdkNamedImportText( ops: OperationModel[], sdkModule: string, hasQuery: boolean -): ts.Statement { +): string { const values = ops.map((op) => op.name).sort(); const types = ops.filter(hasInputs).map(variablesName).sort(); if (hasQuery) types.push('RequestOptions'); - - const specifiers = [ - ...values.map((name) => - factory.createImportSpecifier(false, undefined, factory.createIdentifier(name)) - ), - ...types.map((name) => - factory.createImportSpecifier(true, undefined, factory.createIdentifier(name)) - ), - ]; - return factory.createImportDeclaration( - undefined, - factory.createImportClause(false, undefined, factory.createNamedImports(specifiers)), - factory.createStringLiteral(sdkModule) - ); + const specifiers = [...values, ...types.map((name) => `type ${name}`)].join(', '); + return `import { ${specifiers} } from ${JSON.stringify(sdkModule)};`; } From a74b943e301c8e164c677d4f48bd4a88b14681c9 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 14:08:44 +0300 Subject: [PATCH 054/211] feat(client-generator): transformers on text templates --- .../src/emitters/transformers.ts | 454 +++++++----------- 1 file changed, 183 insertions(+), 271 deletions(-) diff --git a/packages/client-generator/src/emitters/transformers.ts b/packages/client-generator/src/emitters/transformers.ts index d76258c3dd..e4ec94587b 100644 --- a/packages/client-generator/src/emitters/transformers.ts +++ b/packages/client-generator/src/emitters/transformers.ts @@ -6,8 +6,8 @@ // // Pairs with the sdk generated under `dateType: 'Date'`; the client itself // stays zero-dep (Date is standard). Transformers compose across refs: -// `transformPet` calls `transformPerson(data["owner"])` when `Pet.owner` is a -// `Person` that has dates. +// `transformPet` calls `transformOwner(data["owner"])` when `Pet.owner` is an +// `Owner` that has dates. Source-text templates throughout. import type { ApiModel, @@ -16,9 +16,8 @@ import type { } from '../intermediate-representation/model.js'; import { safeIdent } from './identifier.js'; import { pascalCase } from './support.js'; -import { arrow, exportConstStatement, parseStatements, printStatements, ts } from './ts.js'; -const { factory } = ts; +const INDENT = ' '; /** `transform` — the function bound to a named schema. */ function transformName(name: string): string { @@ -33,6 +32,22 @@ const WRITABLE_DECL = 'type __Writable = { -readonly [K in keyof T]: T[K] };' /** Set by `writableLhs` during a render; `renderTransformersModule` resets and reads it. */ let writableUsed = false; +/** + * A write target: the rendered expression plus its access path (base identifier + * followed by string keys), so the `readonly` cast can rebuild the + * `NonNullable` chain. Loop variables have a bare one-segment path. + */ +type Target = { text: string; path: string[] }; + +function ident(name: string): Target { + return { text: name, path: [name] }; +} + +/** `["key"]` — bracket access, robust for any (incl. non-identifier) key. */ +function index(target: Target, key: string): Target { + return { text: `${target.text}[${JSON.stringify(key)}]`, path: [...target.path, key] }; +} + /** * Whether transforming a value of `schema` REPLACES it (so the result must be * assigned back) rather than mutating it in place: date scalars, arrays of @@ -101,67 +116,29 @@ function hasDates( } } -/** `["key"]` — bracket access, robust for any (incl. non-identifier) key. */ -function index(target: ts.Expression, key: string): ts.ElementAccessExpression { - return factory.createElementAccessExpression(target, factory.createStringLiteral(key)); -} - -/** `new Date()`. */ -function newDate(arg: ts.Expression): ts.Expression { - return factory.createNewExpression(factory.createIdentifier('Date'), undefined, [arg]); -} - -/** `typeof === "string"`. */ -function isStringGuard(expr: ts.Expression): ts.Expression { - return factory.createBinaryExpression( - factory.createTypeOfExpression(expr), - factory.createToken(ts.SyntaxKind.EqualsEqualsEqualsToken), - factory.createStringLiteral('string') - ); -} - -/** `Array.isArray()`. */ -function isArrayGuard(expr: ts.Expression): ts.Expression { - return factory.createCallExpression( - factory.createPropertyAccessExpression(factory.createIdentifier('Array'), 'isArray'), - undefined, - [expr] - ); -} - -/** ` && typeof === "object"` — truthy and a (non-null) object. */ -function isObjectGuard(expr: ts.Expression): ts.Expression { - return factory.createBinaryExpression( - expr, - factory.createToken(ts.SyntaxKind.AmpersandAmpersandToken), - factory.createBinaryExpression( - factory.createTypeOfExpression(expr), - factory.createToken(ts.SyntaxKind.EqualsEqualsEqualsToken), - factory.createStringLiteral('object') - ) - ); -} - -/** ` as ` — a type assertion, to satisfy a union-narrowing transform. */ -function asType(expr: ts.Expression, typeName: string): ts.Expression { - return factory.createAsExpression(expr, factory.createTypeReferenceNode(typeName)); -} - -/** `if () ;`. */ -function ifThen(cond: ts.Expression, then: ts.Statement): ts.Statement { - return factory.createIfStatement(cond, then); +/** + * `if () …` — a brace-less single-statement `then` prints on the next line one + * level deeper (`block: false`), a braced one wraps in `{ … }` (`block: true`); + * `then` receives the indent its lines must start at. + */ +function ifThen( + cond: string, + then: (indent: string) => string[], + indent: string, + block = false +): string[] { + if (block) return [`${indent}if (${cond}) {`, ...then(indent + INDENT), `${indent}}`]; + return [`${indent}if (${cond})`, ...then(indent + INDENT)]; } -function exprStatement(expr: ts.Expression): ts.Statement { - return factory.createExpressionStatement(expr); +/** ` = ;` — the LHS cast writable when it is a `readonly` property. */ +function assign(target: Target, value: string, readonlyLhs = false): (indent: string) => string[] { + const lhs = readonlyLhs ? writableLhs(target) : target.text; + return (indent) => [`${indent}${lhs} = ${value};`]; } -/** ` = ;` — the LHS cast writable when it is a `readonly` property. */ -function assign(target: ts.Expression, value: ts.Expression, readonlyLhs = false): ts.Statement { - const lhs = readonlyLhs ? writableLhs(target) : target; - return exprStatement( - factory.createBinaryExpression(lhs, factory.createToken(ts.SyntaxKind.EqualsToken), value) - ); +function statement(expr: string): (indent: string) => string[] { + return (indent) => [`${indent}${expr};`]; } /** @@ -169,51 +146,28 @@ function assign(target: ts.Expression, value: ts.Expression, readonlyLhs = false * `(recv as __Writable>)["key"]`. `readonly` is shallow — * it blocks only the direct assignment — so nested writes stay uncast. */ -function writableLhs(lhs: ts.Expression): ts.Expression { - if (!ts.isElementAccessExpression(lhs)) return lhs; // a parameter reassignment is never readonly +function writableLhs(target: Target): string { + if (target.path.length < 2) return target.text; // a parameter reassignment is never readonly writableUsed = true; - const receiver = factory.createParenthesizedExpression( - factory.createAsExpression( - lhs.expression, - factory.createTypeReferenceNode('__Writable', [nonNullTypeOf(lhs.expression)]) - ) - ); - return factory.createElementAccessExpression(receiver, lhs.argumentExpression); + const receiver: Target = { + text: target.text.slice(0, target.text.lastIndexOf('[')), + path: target.path.slice(0, -1), + }; + const key = target.path[target.path.length - 1]; + return `(${receiver.text} as __Writable<${nonNullTypeOf(receiver)}>)[${JSON.stringify(key)}]`; } /** - * `NonNullable>` for the expression chains this emitter builds - * (an identifier indexed by string-literal keys), with `NonNullable` applied at - * every step so optional intermediate properties don't poison the indexed type. + * `NonNullable>` for the access paths this emitter builds, with + * `NonNullable` applied at every step so optional intermediate properties don't + * poison the indexed type. */ -function nonNullTypeOf(expr: ts.Expression): ts.TypeNode { - let base: ts.TypeNode; - if (ts.isIdentifier(expr)) { - base = factory.createTypeQueryNode(expr); - } else if (ts.isElementAccessExpression(expr) && ts.isStringLiteral(expr.argumentExpression)) { - base = factory.createIndexedAccessTypeNode( - nonNullTypeOf(expr.expression), - factory.createLiteralTypeNode(factory.createStringLiteral(expr.argumentExpression.text)) - ); - } else { - // Every write target is built here from `data`/loop identifiers + string-literal - // element access (`index`), so any other shape is an emitter bug. - throw new Error('transformers: unsupported write-target expression'); +function nonNullTypeOf(target: Target): string { + let type = `typeof ${target.path[0]}`; + for (const key of target.path.slice(1)) { + type = `NonNullable<${type}>[${JSON.stringify(key)}]`; } - return factory.createTypeReferenceNode('NonNullable', [base]); -} - -/** `.()`. */ -function method(recv: ts.Expression, name: string, args: ts.Expression[]): ts.Expression { - return factory.createCallExpression( - factory.createPropertyAccessExpression(recv, name), - undefined, - args - ); -} - -function param(name: string): ts.ParameterDeclaration { - return factory.createParameterDeclaration(undefined, undefined, name); + return `NonNullable<${type}>`; } /** Next nested loop variable: `item`, `item2`, `item3`, … (avoids shadowing). */ @@ -223,61 +177,62 @@ function nextItemVar(current: string): string { } /** - * Conversion statements that, given the runtime value at `target` typed by - * `schema`, rewrite date leaves in place. Each branch self-gates by returning - * `[]` when nothing under it carries a date, so callers need no pre-check. - * `seen` follows refs and guards cycles; `itemVar` names nested loop variables. - * - * Covers the shapes a date can hide in: date scalars, arrays of them, refs to - * date-bearing schemas (composed via `transform`), arrays of such refs, - * records, nested inline objects, and the date-bearing members of a - * union/intersection. + * Conversion lines that, given the runtime value at `target` typed by `schema`, + * rewrite date leaves in place. Each branch self-gates by returning `[]` when + * nothing under it carries a date, so callers need no pre-check. `seen` follows + * refs and guards cycles; `itemVar` names nested loop variables. */ function convert( - target: ts.Expression, + target: Target, schema: SchemaModel, byName: Map, seen: Set, itemVar: string, + indent: string, readonlyLhs = false -): ts.Statement[] { +): string[] { if (isDateScalar(schema)) { - return [ifThen(isStringGuard(target), assign(target, newDate(target), readonlyLhs))]; + return ifThen( + `typeof ${target.text} === "string"`, + assign(target, `new Date(${target.text})`, readonlyLhs), + indent + ); } switch (schema.kind) { case 'ref': - return convertRef(target, schema.name, byName, seen, readonlyLhs); + return convertRef(target, schema.name, byName, seen, indent, readonlyLhs); case 'object': { - const stmts: ts.Statement[] = []; + const lines: string[] = []; for (const p of schema.properties) { - stmts.push( + lines.push( ...convertProperty( index(target, p.name), p.schema, byName, seen, itemVar, + indent, p.readOnly === true ) ); } - return stmts; + return lines; } case 'array': - return convertArray(target, schema.items, byName, seen, itemVar, readonlyLhs); + return convertArray(target, schema.items, byName, seen, itemVar, indent, readonlyLhs); case 'record': - return convertCollection(target, schema.value, byName, seen, itemVar, true); + return convertCollection(target, schema.value, byName, seen, itemVar, indent, true); case 'intersection': { // An intersection value satisfies *every* member type, so each member's // transform applies directly to `target` with no narrowing needed. - const stmts: ts.Statement[] = []; + const lines: string[] = []; for (const m of schema.members) { - stmts.push(...convert(target, m, byName, seen, itemVar, readonlyLhs)); + lines.push(...convert(target, m, byName, seen, itemVar, indent, readonlyLhs)); } - return stmts; + return lines; } case 'union': - return convertUnion(target, schema.members, byName, seen, itemVar, readonlyLhs); + return convertUnion(target, schema.members, byName, seen, itemVar, indent, readonlyLhs); default: return []; } @@ -298,39 +253,45 @@ function convert( * `--date-type Date`, so the assignment type-checks). */ function convertUnion( - target: ts.Expression, + target: Target, members: SchemaModel[], byName: Map, seen: Set, itemVar: string, + indent: string, readonlyLhs = false -): ts.Statement[] { - const stmts: ts.Statement[] = []; - const objectGuarded: ts.Statement[] = []; +): string[] { + const lines: string[] = []; + const guardedIndent = indent + INDENT; + const objectGuarded: string[] = []; for (const m of members) { if (isDateScalar(m)) { - stmts.push(...convert(target, m, byName, seen, itemVar, readonlyLhs)); + lines.push(...convert(target, m, byName, seen, itemVar, indent, readonlyLhs)); } else if (m.kind === 'ref') { if (!hasDates(m, byName, seen)) continue; - const call = factory.createCallExpression( - factory.createIdentifier(transformName(m.name)), - undefined, - [asType(target, m.name)] - ); + const call = `${transformName(m.name)}(${target.text} as ${m.name})`; // A replace-by-value ref (scalar dates) must be assigned back; an object // ref mutates in place, so its return can be dropped. - objectGuarded.push( - needsReassign(m, byName, seen) ? assign(target, call, readonlyLhs) : exprStatement(call) - ); + const build = needsReassign(m, byName, seen) + ? assign(target, call, readonlyLhs) + : statement(call); + objectGuarded.push(...build(guardedIndent)); } else { // Object/array/record members: recurse under the shared object guard. - objectGuarded.push(...convert(target, m, byName, seen, itemVar, readonlyLhs)); + objectGuarded.push(...convert(target, m, byName, seen, itemVar, guardedIndent, readonlyLhs)); } } if (objectGuarded.length > 0) { - stmts.push(ifThen(isObjectGuard(target), factory.createBlock(objectGuarded, true))); + lines.push( + ...ifThen( + `${target.text} && typeof ${target.text} === "object"`, + () => objectGuarded, + indent, + true + ) + ); } - return stmts; + return lines; } /** @@ -341,25 +302,21 @@ function convertUnion( * assigned back: `if () = transform();`. */ function convertRef( - target: ts.Expression, + target: Target, name: string, byName: Map, seen: Set, + indent: string, readonlyLhs = false -): ts.Statement[] { +): string[] { const ref: SchemaModel = { kind: 'ref', name }; if (!hasDates(ref, byName, seen)) return []; - const call = factory.createCallExpression( - factory.createIdentifier(transformName(name)), - undefined, - [target] + const call = `${transformName(name)}(${target.text})`; + return ifThen( + target.text, + needsReassign(ref, byName, seen) ? assign(target, call, readonlyLhs) : statement(call), + indent ); - return [ - ifThen( - target, - needsReassign(ref, byName, seen) ? assign(target, call, readonlyLhs) : exprStatement(call) - ), - ]; } /** @@ -368,20 +325,24 @@ function convertRef( * `convert`, which guards itself. */ function convertProperty( - target: ts.Expression, + target: Target, schema: SchemaModel, byName: Map, seen: Set, itemVar: string, + indent: string, readonlyLhs = false -): ts.Statement[] { - if (schema.kind === 'ref') return convertRef(target, schema.name, byName, seen, readonlyLhs); +): string[] { + if (schema.kind === 'ref') { + return convertRef(target, schema.name, byName, seen, indent, readonlyLhs); + } if (schema.kind === 'object') { // Nested writes go one level inside — `readonly` is shallow, so no cast needed. - const inner = convert(target, schema, byName, seen, itemVar); - return inner.length === 0 ? [] : [ifThen(target, factory.createBlock(inner, true))]; + const inner = convert(target, schema, byName, seen, itemVar, indent + INDENT); + if (inner.length === 0) return []; + return ifThen(target.text, () => inner, indent, true); } - return convert(target, schema, byName, seen, itemVar, readonlyLhs); + return convert(target, schema, byName, seen, itemVar, indent, readonlyLhs); } /** @@ -390,43 +351,28 @@ function convertProperty( * such elements (`v.map(...)`). Returns the expression that yields the replaced * value for the element bound to `value`, or `null` when the element instead * mutates in place (object/ref/record). Recurses for arrays-of-arrays. - * - * Reassigning a loop *variable* is a no-op, so date scalars (and arrays of - * them) can only be converted by reassigning their container slot — an array - * via `slot = slot.map(...)`, a record via per-key assignment. This builds the - * per-element value for those write-backs. */ function replacer( - value: ts.Expression, + value: string, element: SchemaModel, byName: Map, seen: Set, depth = 0 -): ts.Expression | null { - if (isDateScalar(element)) return newDate(value); +): string | null { + if (isDateScalar(element)) return `new Date(${value})`; // A ref resolving to a replace-by-value shape (a scalar-date named schema): // its sibling transform returns the converted value — `transform(v)`. if (element.kind === 'ref' && needsReassign(element, byName, seen)) { - return factory.createCallExpression( - factory.createIdentifier(transformName(element.name)), - undefined, - [value] - ); + return `${transformName(element.name)}(${value})`; } if (element.kind === 'array') { // Map var for the level below: `v` over the scalar leaf, else `row`, `row2`, // … per array level — distinct names by depth avoid shadowing. Yields - // `.map((v) => new Date(v))` and `.map((row) => row.map((v) => new Date(v)))`. + // `.map(v => new Date(v))` and `.map(row => row.map(v => new Date(v)))`. const varName = element.items.kind === 'array' ? rowVar(depth + 1) : 'v'; - const inner = replacer( - factory.createIdentifier(varName), - element.items, - byName, - seen, - depth + 1 - ); + const inner = replacer(varName, element.items, byName, seen, depth + 1); if (inner === null) return null; - return method(value, 'map', [arrow([param(varName)], inner)]); + return `${value}.map(${varName} => ${inner})`; } return null; } @@ -438,35 +384,36 @@ function rowVar(depth: number): string { /** Conversions for `target` being an array whose elements are typed by `items`. */ function convertArray( - target: ts.Expression, + target: Target, items: SchemaModel, byName: Map, seen: Set, itemVar: string, + indent: string, readonlyLhs = false -): ts.Statement[] { +): string[] { // Date scalars / arrays-of-date-scalars are replace-by-value: map over the // array and reassign the slot (reassigning a loop var would be lost). const varName = items.kind === 'array' ? rowVar(1) : 'v'; - const mapped = replacer(factory.createIdentifier(varName), items, byName, seen, 1); + const mapped = replacer(varName, items, byName, seen, 1); if (mapped !== null) { - // `if (Array.isArray(t)) t = t.map((v) => new Date(v));` (or nested `row`) - return [ - ifThen( - isArrayGuard(target), - assign(target, method(target, 'map', [arrow([param(varName)], mapped)]), readonlyLhs) - ), - ]; + // `if (Array.isArray(t)) t = t.map(v => new Date(v));` (or nested `row`) + return ifThen( + `Array.isArray(${target.text})`, + assign(target, `${target.text}.map(${varName} => ${mapped})`, readonlyLhs), + indent + ); } if (items.kind === 'ref') { if (!hasDates(items, byName, seen)) return []; // `if (Array.isArray(t)) t.forEach(transformRef);` - const forEach = method(target, 'forEach', [ - factory.createIdentifier(transformName(items.name)), - ]); - return [ifThen(isArrayGuard(target), exprStatement(forEach))]; + return ifThen( + `Array.isArray(${target.text})`, + statement(`${target.text}.forEach(${transformName(items.name)})`), + indent + ); } - return convertCollection(target, items, byName, seen, itemVar, false); + return convertCollection(target, items, byName, seen, itemVar, indent, false); } /** @@ -478,102 +425,67 @@ function convertArray( * Replace-by-value elements (date scalars) never reach the array path here — * `convertArray` handles them via map-and-reassign. A *record* of date scalars * does land here: a `forEach` loop variable can't write back, so we iterate the - * keys and assign back into the record (`rec[k] = new Date(rec[k])`). + * keys and assign back into the record (`rec[__k] = new Date(rec[__k])`). */ function convertCollection( - target: ts.Expression, + target: Target, element: SchemaModel, byName: Map, seen: Set, itemVar: string, + indent: string, isRecord: boolean -): ts.Statement[] { +): string[] { if (isRecord) { // Replace-by-value elements (date scalars, arrays of them) can't be written // through a `forEach` loop var, so iterate the keys and assign back into the // record slot. Date scalars are string-guarded; nested arrays array-guarded. - const slot = factory.createElementAccessExpression(target, factory.createIdentifier('__k')); - const replaced = replacer(slot, element, byName, seen); + const slot: Target = { text: `${target.text}[__k]`, path: [...target.path, '__k'] }; + const replaced = replacer(slot.text, element, byName, seen); if (replaced !== null) { - const guard = isDateScalar(element) ? isStringGuard(slot) : isArrayGuard(slot); - return [ifThen(target, keyLoop(target, ifThen(guard, assign(slot, replaced))))]; + const guard = isDateScalar(element) + ? `typeof ${slot.text} === "string"` + : `Array.isArray(${slot.text})`; + return ifThen( + target.text, + (loopIndent) => [ + `${loopIndent}for (const __k of Object.keys(${target.text}))`, + ...ifThen(guard, (inner) => [`${inner}${slot.text} = ${replaced};`], loopIndent + INDENT), + ], + indent + ); } } const next = nextItemVar(itemVar); - const body = convert(factory.createIdentifier(next), element, byName, seen, next); + const body = convert(ident(next), element, byName, seen, next, indent + INDENT); if (body.length === 0) return []; - const iterable = isRecord - ? method(factory.createIdentifier('Object'), 'values', [target]) - : target; - const forEach = method(iterable, 'forEach', [ - arrow([param(next)], factory.createBlock(body, true)), - ]); - return [ifThen(isRecord ? target : isArrayGuard(target), exprStatement(forEach))]; -} - -/** `for (const __k of Object.keys()) `. */ -function keyLoop(target: ts.Expression, body: ts.Statement): ts.Statement { - return factory.createForOfStatement( - undefined, - factory.createVariableDeclarationList( - [factory.createVariableDeclaration('__k')], - ts.NodeFlags.Const - ), - method(factory.createIdentifier('Object'), 'keys', [target]), - body + const iterable = isRecord ? `Object.values(${target.text})` : target.text; + return ifThen( + isRecord ? target.text : `Array.isArray(${target.text})`, + (inner) => [ + `${inner}${iterable}.forEach(${next} => {`, + ...convert(ident(next), element, byName, seen, next, inner + INDENT), + `${inner}});`, + ], + indent ); } /** `export const transform = (data: ): => { … };`. */ -function transformStatement( - named: NamedSchemaModel, - byName: Map -): ts.Statement { +function transformBlock(named: NamedSchemaModel, byName: Map): string { // The sdk exports the type verbatim; only the `transform` NAME is PascalCased. const typeName = named.name; - const data = factory.createIdentifier('data'); + const data = ident('data'); const body = named.schema.kind === 'ref' - ? convertRef(data, named.schema.name, byName, new Set()) - : convert(data, named.schema, byName, new Set(), 'data'); - const fn = arrow( - [ - factory.createParameterDeclaration( - undefined, - undefined, - 'data', - undefined, - factory.createTypeReferenceNode(typeName) - ), - ], - factory.createBlock([...body, factory.createReturnStatement(data)], true) - ); - const typed = factory.createArrowFunction( - fn.modifiers, - fn.typeParameters, - fn.parameters, - factory.createTypeReferenceNode(typeName), - fn.equalsGreaterThanToken, - fn.body - ); - return exportConstStatement(transformName(named.name), typed); -} - -/** `import type { , … } from "";`. */ -function typeImport(names: string[], module: string): ts.Statement { - return factory.createImportDeclaration( - undefined, - factory.createImportClause( - true, - undefined, - factory.createNamedImports( - names.map((n) => - factory.createImportSpecifier(false, undefined, factory.createIdentifier(safeIdent(n))) - ) - ) - ), - factory.createStringLiteral(module) - ); + ? convertRef(data, named.schema.name, byName, new Set(), INDENT) + : convert(data, named.schema, byName, new Set(), 'data', INDENT); + return [ + `export const ${transformName(named.name)} = (data: ${typeName}): ${typeName} => {`, + ...body, + `${INDENT}return data;`, + '};', + ].join('\n'); } /** @@ -586,13 +498,13 @@ export function renderTransformersModule(model: ApiModel, opts: { sdkModule: str const byName = new Map(model.schemas.map((s) => [s.name, s.schema])); const dated = model.schemas.filter((s) => hasDates(s.schema, byName, new Set())); if (dated.length === 0) return ''; - const types = dated.map((s) => s.name); + const types = dated.map((s) => safeIdent(s.name)).join(', '); writableUsed = false; // reset the per-render flag `writableLhs` sets - const transforms = dated.map((s) => transformStatement(s, byName)); - const statements = [ - typeImport(types, opts.sdkModule), - ...(writableUsed ? parseStatements(WRITABLE_DECL) : []), + const transforms = dated.map((s) => transformBlock(s, byName)); + const blocks = [ + `import type { ${types} } from ${JSON.stringify(opts.sdkModule)};`, + ...(writableUsed ? [WRITABLE_DECL] : []), ...transforms, ]; - return printStatements(statements); + return blocks.join('\n\n'); } From cfbfd3105f8593b8912cc64c513a2aec92acaaaf Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 14:13:26 +0300 Subject: [PATCH 055/211] feat(client-generator): zod on text templates --- .../src/emitters/__tests__/zod.test.ts | 6 +- packages/client-generator/src/emitters/zod.ts | 343 ++++++------------ 2 files changed, 114 insertions(+), 235 deletions(-) diff --git a/packages/client-generator/src/emitters/__tests__/zod.test.ts b/packages/client-generator/src/emitters/__tests__/zod.test.ts index 48334a09da..7931f55dcc 100644 --- a/packages/client-generator/src/emitters/__tests__/zod.test.ts +++ b/packages/client-generator/src/emitters/__tests__/zod.test.ts @@ -1,5 +1,4 @@ import type { NamedSchemaModel, SchemaModel } from '../../intermediate-representation/model.js'; -import { printStatements } from '../ts.js'; import { renderZodModule, schemaToZodExpression } from '../zod.js'; import { apiModel, operation, response } from './fixtures.js'; @@ -438,8 +437,7 @@ describe('renderZodModule — operation validation surface', () => { }); describe('schemaToZodExpression — direct export', () => { - it('is callable directly and returns an expression node', () => { - const node = schemaToZodExpression({ kind: 'scalar', scalar: 'string' }); - expect(printStatements([node])).toBe('z.string()'); + it('is callable directly and returns the expression source text', () => { + expect(schemaToZodExpression({ kind: 'scalar', scalar: 'string' })).toBe('z.string()'); }); }); diff --git a/packages/client-generator/src/emitters/zod.ts b/packages/client-generator/src/emitters/zod.ts index 47c8e70b7b..576452f9e9 100644 --- a/packages/client-generator/src/emitters/zod.ts +++ b/packages/client-generator/src/emitters/zod.ts @@ -1,6 +1,6 @@ // Emits Zod schemas from the IR. Each named schema becomes an -// `export const Schema = z.<…>;` built with `ts.factory`, mirroring the -// type emitter (`types.ts`) but targeting runtime validators instead of types. +// `export const Schema = z.<…>;` — source-text templates mirroring the +// type emitter (`ts-type.ts`) but targeting runtime validators instead of types. // Operations with a JSON request or response body additionally land in the // `operationSchemas` map, which powers the `zodValidation` client middleware. // @@ -12,7 +12,6 @@ import { allOperations, type ApiModel, - type NamedSchemaModel, type PropertyModel, type ScalarKind, type SchemaMetadata, @@ -21,149 +20,109 @@ import { import { safeIdent } from './identifier.js'; import { isSseOp } from './sse.js'; import { pascalCase } from './support.js'; -import { jsdoc, literalExpression, printStatements, ts } from './ts.js'; +import { codeLiteral } from './ts-literal.js'; -const { factory } = ts; +const INDENT = ' '; /** `Schema` — the const identifier a named schema is bound to. */ function schemaConstName(name: string): string { return `${pascalCase(name)}Schema`; } -/** `z` member access: `z.`. */ -function zMember(method: string): ts.Expression { - return factory.createPropertyAccessExpression(factory.createIdentifier('z'), method); -} - -/** `z.(...args)`. */ -function zCall(method: string, args: ts.Expression[] = []): ts.CallExpression { - return factory.createCallExpression(zMember(method), undefined, args); -} - -/** `.(...args)` — chains a refinement onto a base expression. */ -function chain(expr: ts.Expression, method: string, args: ts.Expression[] = []): ts.CallExpression { - return factory.createCallExpression( - factory.createPropertyAccessExpression(expr, method), - undefined, - args - ); -} - type SchemaByName = ReadonlyMap; const NO_SCHEMAS: SchemaByName = new Map(); -/** Map an IR schema to the Zod expression that validates it. */ +/** Map an IR schema to the Zod expression (source text) that validates it. */ export function schemaToZodExpression( schema: SchemaModel, - byName: SchemaByName = NO_SCHEMAS -): ts.Expression { - return withRefinements(baseExpression(schema, byName), schema); + byName: SchemaByName = NO_SCHEMAS, + indent = '' +): string { + return withRefinements(baseExpression(schema, byName, indent), schema); } -function baseExpression(schema: SchemaModel, byName: SchemaByName): ts.Expression { +function baseExpression(schema: SchemaModel, byName: SchemaByName, indent: string): string { switch (schema.kind) { case 'scalar': return scalarExpression(schema.scalar, schema.metadata); case 'object': - return objectExpression(schema.properties, byName); + return objectExpression(schema.properties, byName, indent); case 'array': - return zCall('array', [schemaToZodExpression(schema.items, byName)]); + return `z.array(${schemaToZodExpression(schema.items, byName, indent)})`; case 'record': - return zCall('record', [zCall('string'), schemaToZodExpression(schema.value, byName)]); + return `z.record(z.string(), ${schemaToZodExpression(schema.value, byName, indent)})`; case 'ref': - return lazyRef(schema.name); + return `z.lazy(() => ${schemaConstName(schema.name)})`; case 'literal': - return zCall('literal', [literalExpression(schema.value)]); + return `z.literal(${codeLiteral(schema.value)})`; case 'enum': return enumExpression(schema.values); case 'union': - return unionExpression(schema.members, byName); + return unionExpression(schema.members, byName, indent); case 'intersection': - return intersectionExpression(schema.members, byName); + return schema.members + .map((member) => schemaToZodExpression(member, byName, indent)) + .reduce((acc, next) => `${acc}.and(${next})`); case 'null': - return zCall('null'); + return 'z.null()'; case 'unknown': - return zCall('unknown'); + return 'z.unknown()'; case 'omit': - return omitExpression(schema.base, schema.keys, byName); + return omitExpression(schema.base, schema.keys, byName, indent); } } -function scalarExpression(scalar: ScalarKind, metadata?: SchemaMetadata): ts.Expression { +function scalarExpression(scalar: ScalarKind, metadata?: SchemaMetadata): string { switch (scalar) { case 'string': - // `format: binary` is typed as `Blob` (see types.ts); validate it as one so the zod - // schema agrees with the generated type instead of expecting a string. - if (metadata?.format === 'binary') { - return zCall('instanceof', [factory.createIdentifier('Blob')]); - } - return zCall('string'); + // `format: binary` is typed as `Blob` (see ts-type.ts); validate it as one so the + // zod schema agrees with the generated type instead of expecting a string. + return metadata?.format === 'binary' ? 'z.instanceof(Blob)' : 'z.string()'; case 'integer': - return chain(zCall('number'), 'int'); + return 'z.number().int()'; case 'number': - return zCall('number'); + return 'z.number()'; case 'boolean': - return zCall('boolean'); + return 'z.boolean()'; } } -/** `z.object({ : (.optional() when !required), … })`. */ -function objectExpression(properties: PropertyModel[], byName: SchemaByName): ts.Expression { - const props = properties.map((p) => { - const value = p.required - ? schemaToZodExpression(p.schema, byName) - : chain(schemaToZodExpression(p.schema, byName), 'optional'); - const safe = safeIdent(p.name); - const key = - safe === p.name ? factory.createIdentifier(p.name) : factory.createStringLiteral(p.name); - return factory.createPropertyAssignment(key, value); - }); - return zCall('object', [factory.createObjectLiteralExpression(props, props.length > 0)]); +/** A bare identifier key when valid, a quoted key otherwise. */ +function propertyKeyText(name: string): string { + return safeIdent(name) === name ? name : JSON.stringify(name); } -/** `z.lazy(() => Schema)` — defers reference resolution to call time. */ -function lazyRef(name: string): ts.Expression { - const arrow = factory.createArrowFunction( - undefined, - undefined, - [], - undefined, - factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - factory.createIdentifier(schemaConstName(name)) - ); - return zCall('lazy', [arrow]); +/** `z.object({ : (.optional() when !required), … })` — multiline when non-empty. */ +function objectExpression( + properties: PropertyModel[], + byName: SchemaByName, + indent: string +): string { + if (properties.length === 0) return 'z.object({})'; + const inner = indent + INDENT; + const lines = properties.map((property, index) => { + const expr = schemaToZodExpression(property.schema, byName, inner); + const value = property.required ? expr : `${expr}.optional()`; + const comma = index === properties.length - 1 ? '' : ','; + return `${inner}${propertyKeyText(property.name)}: ${value}${comma}`; + }); + return `z.object({\n${lines.join('\n')}\n${indent}})`; } /** All-string values → `z.enum([…])`; otherwise → a union of literals. */ -function enumExpression(values: Array): ts.Expression { - if (values.every((v) => typeof v === 'string')) { - return zCall('enum', [ - factory.createArrayLiteralExpression( - values.map((v) => factory.createStringLiteral(v as string)), - false - ), - ]); +function enumExpression(values: Array): string { + if (values.every((value) => typeof value === 'string')) { + return `z.enum([${values.map((value) => JSON.stringify(value)).join(', ')}])`; } - return zCall('union', [ - factory.createArrayLiteralExpression( - values.map((v) => zCall('literal', [literalExpression(v)])), - false - ), - ]); + return `z.union([${values.map((value) => `z.literal(${codeLiteral(value)})`).join(', ')}])`; } /** `z.union([…])`; a single member collapses to that member's expression. */ -function unionExpression(members: SchemaModel[], byName: SchemaByName): ts.Expression { - const exprs = members.map((member) => schemaToZodExpression(member, byName)); +function unionExpression(members: SchemaModel[], byName: SchemaByName, indent: string): string { + const exprs = members.map((member) => schemaToZodExpression(member, byName, indent)); if (exprs.length === 1) return exprs[0]; - return zCall('union', [factory.createArrayLiteralExpression(exprs, false)]); -} - -/** `a.and(b).and(c)` — left-folds `.and` over the members. */ -function intersectionExpression(members: SchemaModel[], byName: SchemaByName): ts.Expression { - const exprs = members.map((member) => schemaToZodExpression(member, byName)); - return exprs.reduce((acc, next) => chain(acc, 'and', [next])); + return `z.union([${exprs.join(', ')}])`; } /** @@ -171,20 +130,18 @@ function intersectionExpression(members: SchemaModel[], byName: SchemaByName): t * `.omit` exists only on `ZodObject` — for any other base (an `allOf` intersection, * a union, …) the omission is distributed into the base's object members instead. */ -function omitExpression(base: string, keys: string[], byName: SchemaByName): ts.Expression { +function omitExpression( + base: string, + keys: string[], + byName: SchemaByName, + indent: string +): string { const target = byName.get(base); if (target && target.kind !== 'object') { - return schemaToZodExpression(applyOmit(target, keys, byName, new Set([base])), byName); + return schemaToZodExpression(applyOmit(target, keys, byName, new Set([base])), byName, indent); } - const mask = factory.createObjectLiteralExpression( - keys.map((k) => { - const safe = safeIdent(k); - const key = safe === k ? factory.createIdentifier(k) : factory.createStringLiteral(k); - return factory.createPropertyAssignment(key, factory.createTrue()); - }), - false - ); - return chain(factory.createIdentifier(schemaConstName(base)), 'omit', [mask]); + const mask = keys.map((key) => `${propertyKeyText(key)}: true`).join(', '); + return `${schemaConstName(base)}.omit({ ${mask} })`; } /** @@ -231,83 +188,35 @@ function applyOmit( * `.optional()` is NOT applied here — optionality is a property-level concern * handled in `objectExpression`, so a top-level schema is never spuriously optional. */ -function withRefinements(expr: ts.Expression, schema: SchemaModel): ts.Expression { +function withRefinements(expr: string, schema: SchemaModel): string { const m = schema.metadata; if (!m) return expr; let out = expr; if (schema.kind === 'scalar' && schema.scalar === 'string') { - if (m.minLength !== undefined) out = chain(out, 'min', [literalExpression(m.minLength)]); - if (m.maxLength !== undefined) out = chain(out, 'max', [literalExpression(m.maxLength)]); - if (m.pattern !== undefined) out = chain(out, 'regex', [regexExpression(m.pattern)]); + if (m.minLength !== undefined) out = `${out}.min(${m.minLength})`; + if (m.maxLength !== undefined) out = `${out}.max(${m.maxLength})`; + if (m.pattern !== undefined) out = `${out}.regex(new RegExp(${JSON.stringify(m.pattern)}))`; } if (schema.kind === 'scalar' && (schema.scalar === 'number' || schema.scalar === 'integer')) { - out = numericRefinements(out, m); + if (m.minimum !== undefined) out = `${out}.min(${m.minimum})`; + if (m.maximum !== undefined) out = `${out}.max(${m.maximum})`; + if (m.exclusiveMinimum !== undefined) out = `${out}.gt(${m.exclusiveMinimum})`; + if (m.exclusiveMaximum !== undefined) out = `${out}.lt(${m.exclusiveMaximum})`; } if (schema.kind === 'array') { - if (m.minItems !== undefined) out = chain(out, 'min', [literalExpression(m.minItems)]); - if (m.maxItems !== undefined) out = chain(out, 'max', [literalExpression(m.maxItems)]); + if (m.minItems !== undefined) out = `${out}.min(${m.minItems})`; + if (m.maxItems !== undefined) out = `${out}.max(${m.maxItems})`; } return out; } -function numericRefinements(expr: ts.Expression, m: SchemaMetadata): ts.Expression { - let out = expr; - if (m.minimum !== undefined) out = chain(out, 'min', [literalExpression(m.minimum)]); - if (m.maximum !== undefined) out = chain(out, 'max', [literalExpression(m.maximum)]); - if (m.exclusiveMinimum !== undefined) - out = chain(out, 'gt', [literalExpression(m.exclusiveMinimum)]); - if (m.exclusiveMaximum !== undefined) - out = chain(out, 'lt', [literalExpression(m.exclusiveMaximum)]); - return out; -} - -/** `new RegExp("")` — robust across printers regardless of pattern content. */ -function regexExpression(pattern: string): ts.Expression { - return factory.createNewExpression(factory.createIdentifier('RegExp'), undefined, [ - factory.createStringLiteral(pattern), - ]); -} - -/** `export const Schema = ;` for one named schema. */ -function schemaConstStatement(named: NamedSchemaModel, byName: SchemaByName): ts.Statement { - return factory.createVariableStatement( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - factory.createVariableDeclarationList( - [ - factory.createVariableDeclaration( - schemaConstName(named.name), - undefined, - undefined, - schemaToZodExpression(named.schema, byName) - ), - ], - ts.NodeFlags.Const - ) - ); -} - -/** `import { z } from 'zod';` */ -function zodImport(): ts.Statement { - return factory.createImportDeclaration( - undefined, - factory.createImportClause( - false, - undefined, - factory.createNamedImports([ - factory.createImportSpecifier(false, undefined, factory.createIdentifier('z')), - ]) - ), - factory.createStringLiteral('zod') - ); -} - /** * `: { request?: , response?: }` for every non-SSE operation with a * JSON request or response body — the operation's validators, keyed by the same id the * middleware sees at runtime (`ctx.operation.id`). SSE, binary, text, and void bodies * have no JSON payload to validate and are skipped. */ -type OperationSchemaEntry = { name: string; request?: ts.Expression; response?: ts.Expression }; +type OperationSchemaEntry = { name: string; request?: string; response?: string }; function operationSchemaEntries(model: ApiModel, byName: SchemaByName): OperationSchemaEntry[] { const entries: OperationSchemaEntry[] = []; @@ -316,12 +225,14 @@ function operationSchemaEntries(model: ApiModel, byName: SchemaByName): Operatio const requestBody = op.requestBody; const request = requestBody && requestBody.contentType.toLowerCase().includes('json') - ? schemaToZodExpression(requestBody.schema, byName) + ? schemaToZodExpression(requestBody.schema, byName, INDENT) : undefined; const jsonResponse = op.successResponses.find((response) => response.contentType.toLowerCase().includes('json') ); - const response = jsonResponse ? schemaToZodExpression(jsonResponse.schema, byName) : undefined; + const response = jsonResponse + ? schemaToZodExpression(jsonResponse.schema, byName, INDENT) + : undefined; if (!request && !response) continue; // The SPEC operationId — the middleware looks entries up by `ctx.operation.id`, // which stays the spec id even when the emitted function name was renamed. @@ -330,67 +241,34 @@ function operationSchemaEntries(model: ApiModel, byName: SchemaByName): Operatio return entries; } -/** An entry key as a printable property name: bare when a safe identifier, quoted otherwise. */ -function entryKey(name: string): ts.PropertyName { - return safeIdent(name) === name - ? factory.createIdentifier(name) - : factory.createStringLiteral(name); -} - -function operationSchemasStatement(entries: OperationSchemaEntry[]): ts.Statement { - const zodTypeNode = () => - factory.createTypeReferenceNode( - factory.createQualifiedName(factory.createIdentifier('z'), 'ZodType') - ); +function operationSchemasBlock(entries: OperationSchemaEntry[]): string { // The explicit `z.ZodType` annotation keeps the declaration-emit size proportional to // the operation count: the inferred type would serialize every schema's zod generics // and overflow tsc's limit (TS7056) on large APIs under `declaration: true`. - const typeMembers = entries.map((entry) => - factory.createPropertySignature( - undefined, - entryKey(entry.name), - undefined, - factory.createTypeLiteralNode( - [ - entry.request - ? factory.createPropertySignature(undefined, 'request', undefined, zodTypeNode()) - : undefined, - entry.response - ? factory.createPropertySignature(undefined, 'response', undefined, zodTypeNode()) - : undefined, - ].filter((member) => member !== undefined) - ) - ) - ); - const valueEntries = entries.map((entry) => - factory.createPropertyAssignment( - entryKey(entry.name), - factory.createObjectLiteralExpression( - [ - entry.request ? factory.createPropertyAssignment('request', entry.request) : undefined, - entry.response ? factory.createPropertyAssignment('response', entry.response) : undefined, - ].filter((property) => property !== undefined), - false - ) - ) - ); - return jsdoc( - factory.createVariableStatement( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - factory.createVariableDeclarationList( - [ - factory.createVariableDeclaration( - 'operationSchemas', - undefined, - factory.createTypeLiteralNode(typeMembers), - factory.createObjectLiteralExpression(valueEntries, true) - ), - ], - ts.NodeFlags.Const - ) - ), - 'Request/response validators by operationId — powers `zodValidation`, or import one directly.' - ); + const typeLines = entries.flatMap((entry) => [ + `${INDENT}${propertyKeyText(entry.name)}: {`, + ...(entry.request ? [`${INDENT}${INDENT}request: z.ZodType;`] : []), + ...(entry.response ? [`${INDENT}${INDENT}response: z.ZodType;`] : []), + `${INDENT}};`, + ]); + const valueLines = entries.map((entry, index) => { + const fields = [ + ...(entry.request ? [`request: ${entry.request}`] : []), + ...(entry.response ? [`response: ${entry.response}`] : []), + ].join(', '); + const comma = index === entries.length - 1 ? '' : ','; + return `${INDENT}${propertyKeyText(entry.name)}: { ${fields} }${comma}`; + }); + return [ + '/**', + ' * Request/response validators by operationId — powers `zodValidation`, or import one directly.', + ' */', + 'export const operationSchemas: {', + ...typeLines, + '} = {', + ...valueLines, + '};', + ].join('\n'); } // The validation middleware, spliced verbatim after the schemas (matches the printer's @@ -552,11 +430,14 @@ export function renderZodModule(model: ApiModel): string { const byName: SchemaByName = new Map(model.schemas.map((named) => [named.name, named.schema])); const entries = operationSchemaEntries(model, byName); if (model.schemas.length === 0 && entries.length === 0) return ''; - const statements: ts.Statement[] = [ - zodImport(), - ...model.schemas.map((named) => schemaConstStatement(named, byName)), + const blocks = [ + 'import { z } from "zod";', + ...model.schemas.map( + (named) => + `export const ${schemaConstName(named.name)} = ${schemaToZodExpression(named.schema, byName)};` + ), ]; - if (entries.length === 0) return printStatements(statements); - statements.push(operationSchemasStatement(entries)); - return `${printStatements(statements)}\n${VALIDATION_SUPPORT}\n`; + if (entries.length === 0) return blocks.join('\n\n'); + blocks.push(operationSchemasBlock(entries)); + return `${blocks.join('\n\n')}\n${VALIDATION_SUPPORT}\n`; } From 9728c7f0bf7acae14174f4339329990c5234c0a9 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 14:19:42 +0300 Subject: [PATCH 056/211] =?UTF-8?q?feat(client-generator):=20mock=20and=20?= =?UTF-8?q?faker=20on=20text=20templates=20=E2=80=94=20the=20last=20AST=20?= =?UTF-8?q?emitters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/emitters/__tests__/faker.test.ts | 11 +- .../client-generator/src/emitters/faker.ts | 211 ++++------ .../src/emitters/mock-value.ts | 60 +++ .../client-generator/src/emitters/mock.ts | 375 +++++------------- 4 files changed, 227 insertions(+), 430 deletions(-) create mode 100644 packages/client-generator/src/emitters/mock-value.ts diff --git a/packages/client-generator/src/emitters/__tests__/faker.test.ts b/packages/client-generator/src/emitters/__tests__/faker.test.ts index 0c22d41072..ea5a167fe3 100644 --- a/packages/client-generator/src/emitters/__tests__/faker.test.ts +++ b/packages/client-generator/src/emitters/__tests__/faker.test.ts @@ -1,14 +1,14 @@ import type { NamedSchemaModel, SchemaModel } from '../../intermediate-representation/model.js'; import { fakerExpression } from '../faker.js'; -import { printNodes } from '../ts.js'; +import { renderMockValue } from '../mock-value.js'; -/** Emit `schema`'s faker expression and print it to source for substring assertions. */ +/** Emit `schema`'s faker expression and render it to source for substring assertions. */ function emit( schema: SchemaModel, schemas: NamedSchemaModel[] = [], dateType?: 'string' | 'Date' ): string { - return printNodes([fakerExpression(schema, schemas, { dateType })]); + return renderMockValue(fakerExpression(schema, schemas, { dateType }), ''); } describe('fakerExpression', () => { @@ -337,9 +337,10 @@ describe('fakerExpression', () => { }); it('defaults dateType to string when opts is omitted', () => { - const out = printNodes([ + const out = renderMockValue( fakerExpression({ kind: 'scalar', scalar: 'string', metadata: { format: 'date-time' } }, []), - ]); + '' + ); expect(out).toBe('faker.date.recent().toISOString()'); }); diff --git a/packages/client-generator/src/emitters/faker.ts b/packages/client-generator/src/emitters/faker.ts index a19909c42e..590e906b66 100644 --- a/packages/client-generator/src/emitters/faker.ts +++ b/packages/client-generator/src/emitters/faker.ts @@ -1,13 +1,13 @@ -// Builds the body expression for a faker-mode mock factory: a tree of +// Builds the body value for a faker-mode mock factory: a tree of // `@faker-js/faker` call expressions that produce realistic — and, with a seed, // reproducible — data. Structurally mirrors `emitters/sample.ts`'s `walk` (same -// recursion + same visited-set cycle guard), but returns a `ts.Expression` of -// faker calls instead of a static value. Nested refs are INLINED under the same -// cycle guard (never `create()` calls), so a cyclic schema terminates with -// `null` at the cycle instead of recursing forever at runtime — exactly like the -// static path. The factory signatures are identical to the static mode's, so a -// consumer can flip `mockData` without touching call sites; `@faker-js/faker` -// becomes their dev-dep while the real client stays dependency-free. +// recursion + same visited-set cycle guard), but yields faker calls instead of a +// static value. Nested refs are INLINED under the same cycle guard (never +// `create()` calls), so a cyclic schema terminates with `null` at the cycle +// instead of recursing forever at runtime — exactly like the static path. The +// factory signatures are identical to the static mode's, so a consumer can flip +// `mockData` without touching call sites; `@faker-js/faker` becomes their +// dev-dep while the real client stays dependency-free. import type { NamedSchemaModel, @@ -15,14 +15,12 @@ import type { SchemaMetadata, SchemaModel, } from '../intermediate-representation/model.js'; -import { safeIdent } from './identifier.js'; +import { expr, isObjectValue, type MockEntry, type MockValue, objectValue } from './mock-value.js'; import { splitIntersection } from './sample.js'; -import { constArray, literalExpression, ts } from './ts.js'; +import { codeLiteral } from './ts-literal.js'; import type { DateType } from './types.js'; -const { factory } = ts; - -/** The faker-call expression for an IR schema. Refs resolve against `schemas`; +/** The faker-call value for an IR schema. Refs resolve against `schemas`; * recursion is cut with a visited-set (`null` at the cycle). `dateType` mirrors * the sdk's `--date-type`: under `'Date'`, date fields stay `faker.date.recent()` * (a `Date`); otherwise they are stringified to match the `string`-typed sdk. */ @@ -30,12 +28,12 @@ export function fakerExpression( schema: SchemaModel, schemas: NamedSchemaModel[], opts: { dateType?: DateType } = {} -): ts.Expression { +): MockValue { const byName = new Map(schemas.map((s) => [s.name, s.schema])); - const expr = walk(schema, byName, new Set(), opts.dateType ?? 'string'); + const value = walk(schema, byName, new Set(), opts.dateType ?? 'string'); // A `CYCLE` that reaches the root has no container to absorb it (e.g. a // self-referential union); fall back to null. - return expr === CYCLE ? factory.createNull() : expr; + return value === CYCLE ? expr('null') : value; } /** @@ -47,7 +45,7 @@ export function fakerExpression( */ const CYCLE = Symbol('cycle'); -type WalkResult = ts.Expression | typeof CYCLE; +type WalkResult = MockValue | typeof CYCLE; function walk( schema: SchemaModel, @@ -57,39 +55,39 @@ function walk( ): WalkResult { switch (schema.kind) { case 'scalar': - return scalarExpr(schema.scalar, schema.metadata, dateType); + return expr(scalarExpr(schema.scalar, schema.metadata, dateType)); case 'array': { // A cyclic item type collapses the array to `[]` — itself a valid `T[]`. const item = walk(schema.items, byName, visiting, dateType); - return item === CYCLE ? factory.createArrayLiteralExpression([], false) : multiple(item); + return item === CYCLE ? expr('[]') : multiple(item); } case 'object': - return objectExpr( - schema.properties.flatMap((p): Array<[string, ts.Expression]> => { + return objectValue( + schema.properties.flatMap((p): MockEntry[] => { const value = walk(p.schema, byName, visiting, dateType); // A cyclic optional property is omitted; a cyclic required property is // uninhabitable, so null is the only stand-in. - if (value === CYCLE) return p.required ? [[p.name, factory.createNull()]] : []; - return [[p.name, value]]; + if (value === CYCLE) return p.required ? [{ key: p.name, value: expr('null') }] : []; + return [{ key: p.name, value }]; }) ); case 'record': { const value = walk(schema.value, byName, visiting, dateType); - return value === CYCLE - ? factory.createObjectLiteralExpression([], false) - : objectExpr([['key', value]]); + return value === CYCLE ? expr('{}') : objectValue([{ key: 'key', value }]); } case 'enum': - return call('faker.helpers.arrayElement', [constArray(schema.values.map(literalExpression))]); + return expr( + `faker.helpers.arrayElement([${schema.values.map((value) => codeLiteral(value)).join(', ')}] as const)` + ); case 'literal': - return literalExpression(schema.value); + return expr(codeLiteral(schema.value)); case 'union': { // First non-cyclic member; if every member cycles, propagate `CYCLE`. for (const member of schema.members) { const value = walk(member, byName, visiting, dateType); if (value !== CYCLE) return value; } - return schema.members.length > 0 ? CYCLE : factory.createNull(); + return schema.members.length > 0 ? CYCLE : expr('null'); } case 'intersection': { // Mirror the static sampler: object members merge into one synthetic object whose @@ -99,26 +97,25 @@ function walk( const { merged, rest } = splitIntersection(schema.members, byName); const parts = rest .map((member) => walk(member, byName, visiting, dateType)) - .filter((part): part is ts.Expression => part !== CYCLE); + .filter((part): part is MockValue => part !== CYCLE); if (merged) { const value = walk(merged, byName, visiting, dateType); - const own = - value !== CYCLE && ts.isObjectLiteralExpression(value) ? assignments(value) : []; - const folded = parts.filter(ts.isObjectLiteralExpression).flatMap(assignments); - return factory.createObjectLiteralExpression([...own, ...folded], true); + const own = value !== CYCLE && isObjectValue(value) ? value.entries : []; + const folded = parts.filter(isObjectValue).flatMap((part) => part.entries); + return objectValue([...own, ...folded]); } - const objects = parts.filter(ts.isObjectLiteralExpression); + const objects = parts.filter(isObjectValue); if (objects.length > 0) { - return factory.createObjectLiteralExpression(objects.flatMap(assignments), true); + return objectValue(objects.flatMap((part) => part.entries)); } - return parts[0] ?? factory.createObjectLiteralExpression([], true); + return parts[0] ?? objectValue([]); } case 'omit': return omitExpr(schema.base, schema.keys, byName, visiting, dateType); case 'ref': { if (visiting.has(schema.name)) return CYCLE; const target = byName.get(schema.name); - if (!target) return factory.createNull(); + if (!target) return expr('null'); visiting.add(schema.name); const result = walk(target, byName, visiting, dateType); visiting.delete(schema.name); @@ -126,7 +123,7 @@ function walk( } case 'null': case 'unknown': - return factory.createNull(); + return expr('null'); } } @@ -137,75 +134,60 @@ function scalarExpr( scalar: ScalarKind, meta: SchemaMetadata | undefined, dateType: DateType -): ts.Expression { - if (meta?.format === 'binary') return newBlob(); - if (scalar === 'boolean') return call('faker.datatype.boolean', []); - if (scalar === 'integer') return call('faker.number.int', boundsArg(meta)); - if (scalar === 'number') return call('faker.number.float', boundsArg(meta)); +): string { + if (meta?.format === 'binary') return 'new Blob([])'; + if (scalar === 'boolean') return 'faker.datatype.boolean()'; + if (scalar === 'integer') return `faker.number.int(${boundsArg(meta)})`; + if (scalar === 'number') return `faker.number.float(${boundsArg(meta)})`; switch (meta?.format) { case 'email': - return call('faker.internet.email', []); + return 'faker.internet.email()'; case 'uuid': - return call('faker.string.uuid', []); + return 'faker.string.uuid()'; case 'uri': case 'url': - return call('faker.internet.url', []); + return 'faker.internet.url()'; case 'hostname': - return call('faker.internet.domainName', []); + return 'faker.internet.domainName()'; case 'ipv4': - return call('faker.internet.ipv4', []); + return 'faker.internet.ipv4()'; case 'date-time': return dateExpr(dateType, false); case 'date': return dateExpr(dateType, true); default: - return call('faker.lorem.word', []); + return 'faker.lorem.word()'; } } /** `faker.date.recent()` (under `dateType: 'Date'`); else its ISO string, sliced to * `YYYY-MM-DD` for a `date` so the wire shape matches the `string`-typed field. */ -function dateExpr(dateType: DateType, dateOnly: boolean): ts.Expression { - const recent = call('faker.date.recent', []); - if (dateType === 'Date') return recent; - const iso = call(member(recent, 'toISOString'), []); - if (!dateOnly) return iso; - return call(member(iso, 'slice'), [ - factory.createNumericLiteral(0), - factory.createNumericLiteral(10), - ]); +function dateExpr(dateType: DateType, dateOnly: boolean): string { + if (dateType === 'Date') return 'faker.date.recent()'; + const iso = 'faker.date.recent().toISOString()'; + return dateOnly ? `${iso}.slice(0, 10)` : iso; } -/** `{ min, max }` arg list for a bounded numeric, or no args when neither bound is set. */ -function boundsArg(meta: SchemaMetadata | undefined): ts.Expression[] { - const props: ts.PropertyAssignment[] = []; - if (meta?.minimum !== undefined) { - props.push(factory.createPropertyAssignment('min', literalExpression(meta.minimum))); - } - if (meta?.maximum !== undefined) { - props.push(factory.createPropertyAssignment('max', literalExpression(meta.maximum))); - } - return props.length > 0 ? [factory.createObjectLiteralExpression(props, false)] : []; +/** `{ min, max }` arg for a bounded numeric, or empty when neither bound is set. */ +function boundsArg(meta: SchemaMetadata | undefined): string { + const props = [ + ...(meta?.minimum !== undefined ? [`min: ${meta.minimum}`] : []), + ...(meta?.maximum !== undefined ? [`max: ${meta.maximum}`] : []), + ]; + return props.length > 0 ? `{ ${props.join(', ')} }` : ''; } /** `faker.helpers.multiple(() => , { count: 1 })` — one element keeps output small. */ -function multiple(item: ts.Expression): ts.Expression { - const fn = factory.createArrowFunction( - undefined, - undefined, - [], - undefined, - factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - item - ); - const count = factory.createObjectLiteralExpression( - [factory.createPropertyAssignment('count', factory.createNumericLiteral(1))], - false - ); - return call('faker.helpers.multiple', [fn, count]); +function multiple(item: MockValue): MockValue { + return { + kind: 'wrap', + before: 'faker.helpers.multiple(() => ', + value: item, + after: ', { count: 1 })', + }; } -/** An `omit`: the base named schema's faker expr minus the dropped keys. Resolves the +/** An `omit`: the base named schema's faker value minus the dropped keys. Resolves the * base via the schema set (cycle-guarded); a non-object base passes through unchanged. */ function omitExpr( base: string, @@ -215,61 +197,10 @@ function omitExpr( dateType: DateType ): WalkResult { const target = byName.get(base); - if (!target) return factory.createNull(); - const expr = walk(target, byName, visiting, dateType); + if (!target) return expr('null'); + const value = walk(target, byName, visiting, dateType); // A cyclic or non-object base passes through unchanged (a container/root absorbs `CYCLE`). - if (expr === CYCLE || !ts.isObjectLiteralExpression(expr)) return expr; - const drop = new Set(keys.map(safeIdent)); - return factory.createObjectLiteralExpression( - assignments(expr).filter((a) => !drop.has(propKey(a))), - true - ); -} - -/** An object literal from `[key, expr]` entries; keys are quoted when not bare identifiers. */ -function objectExpr(entries: Array<[string, ts.Expression]>): ts.Expression { - return factory.createObjectLiteralExpression( - entries.map(([key, value]) => { - const safe = safeIdent(key); - const name = safe === key ? factory.createIdentifier(key) : factory.createStringLiteral(key); - return factory.createPropertyAssignment(name, value); - }), - true - ); -} - -/** The property assignments of an object literal (the spread/intersection merge unit). */ -function assignments(object: ts.ObjectLiteralExpression): ts.PropertyAssignment[] { - return object.properties.filter((p): p is ts.PropertyAssignment => ts.isPropertyAssignment(p)); -} - -/** The printed key text of a property assignment (matching `safeIdent`'s quoting). */ -function propKey(a: ts.PropertyAssignment): string { - return ts.isStringLiteral(a.name) ? safeIdent(a.name.text) : (a.name as ts.Identifier).text; -} - -/** `new Blob([])` — the type-correct stand-in for a `format: binary` field. */ -function newBlob(): ts.Expression { - return factory.createNewExpression(factory.createIdentifier('Blob'), undefined, [ - factory.createArrayLiteralExpression([], false), - ]); -} - -/** A call expression from a dotted callee name (`faker.number.int`) or a built node. */ -function call(callee: string | ts.Expression, args: ts.Expression[]): ts.CallExpression { - const target = typeof callee === 'string' ? dotted(callee) : callee; - return factory.createCallExpression(target, undefined, args); -} - -/** Turn `a.b.c` into nested property access on an identifier. */ -function dotted(path: string): ts.Expression { - const [head, ...rest] = path.split('.'); - return rest.reduce( - (acc, name) => member(acc, name), - factory.createIdentifier(head) - ); -} - -function member(target: ts.Expression, name: string): ts.PropertyAccessExpression { - return factory.createPropertyAccessExpression(target, name); + if (value === CYCLE || !isObjectValue(value)) return value; + const drop = new Set(keys); + return objectValue(value.entries.filter((entry) => 'spread' in entry || !drop.has(entry.key))); } diff --git a/packages/client-generator/src/emitters/mock-value.ts b/packages/client-generator/src/emitters/mock-value.ts new file mode 100644 index 0000000000..802e286f20 --- /dev/null +++ b/packages/client-generator/src/emitters/mock-value.ts @@ -0,0 +1,60 @@ +// The value tree the mock/faker emitters build and render: keeps object structure +// (for intersection merging and `...overrides` spreading) until the final render, +// where indentation is threaded — the text-template equivalent of passing +// `ts.ObjectLiteralExpression` around. Deliberately tiny. + +import { safeIdent } from './identifier.js'; + +export type MockEntry = { key: string; value: MockValue } | { spread: string }; + +export type MockValue = + | { kind: 'object'; entries: MockEntry[] } + | { kind: 'array'; items: MockValue[] } + | { kind: 'expr'; text: string } + /** A textual wrapper around a nested value (`faker.helpers.multiple(() => , …)`). */ + | { kind: 'wrap'; before: string; value: MockValue; after: string }; + +export const expr = (text: string): MockValue => ({ kind: 'expr', text }); +export const objectValue = (entries: MockEntry[]): MockValue => ({ kind: 'object', entries }); + +export function isObjectValue(value: MockValue): value is Extract { + return value.kind === 'object'; +} + +/** Spread `` into an object value; non-objects pass through unchanged. */ +export function spreadInto(value: MockValue, name: string): MockValue { + if (!isObjectValue(value)) return value; + return objectValue([...value.entries, { spread: name }]); +} + +const INDENT = ' '; + +/** Render at `indent` (the containing line's indent): objects/arrays multiline, printer-style. */ +export function renderMockValue(value: MockValue, indent: string): string { + switch (value.kind) { + case 'expr': + return value.text; + case 'wrap': + return `${value.before}${renderMockValue(value.value, indent)}${value.after}`; + case 'array': { + if (value.items.length === 0) return '[]'; + const inner = indent + INDENT; + const lines = value.items.map( + (item, index) => + `${inner}${renderMockValue(item, inner)}${index === value.items.length - 1 ? '' : ','}` + ); + return `[\n${lines.join('\n')}\n${indent}]`; + } + case 'object': { + if (value.entries.length === 0) return '{}'; + const inner = indent + INDENT; + const lines = value.entries.map((entry, index) => { + const comma = index === value.entries.length - 1 ? '' : ','; + if ('spread' in entry) return `${inner}...${entry.spread}${comma}`; + const key = safeIdent(entry.key) === entry.key ? entry.key : JSON.stringify(entry.key); + return `${inner}${key}: ${renderMockValue(entry.value, inner)}${comma}`; + }); + return `{\n${lines.join('\n')}\n${indent}}`; + } + } +} diff --git a/packages/client-generator/src/emitters/mock.ts b/packages/client-generator/src/emitters/mock.ts index 361b3c3ccd..8370338284 100644 --- a/packages/client-generator/src/emitters/mock.ts +++ b/packages/client-generator/src/emitters/mock.ts @@ -1,9 +1,9 @@ // Emits a `*.mocks.ts` module: a `create(overrides?)` data factory per // named schema, an `Handler(override?)` MSW request handler per operation // (its primary success response), and an aggregated `handlers` array. Response -// data is sampled at codegen time (`sampleValue`) and printed as -// TypeScript literals through `ts.factory`, so the generated module depends only -// on `msw` — the real client stays zero-dependency. +// data is sampled at codegen time (`sampleValue`) and printed as TypeScript +// literals — source-text templates — so the generated module depends only on +// `msw`; the real client stays zero-dependency. import { isPlainObject } from '@redocly/openapi-core'; @@ -16,13 +16,20 @@ import { type SchemaModel, } from '../intermediate-representation/model.js'; import { fakerExpression } from './faker.js'; -import { safeIdent } from './identifier.js'; +import { + expr, + isObjectValue, + type MockValue, + objectValue, + renderMockValue, + spreadInto, +} from './mock-value.js'; import { sampleValue, SampleExpression } from './sample.js'; import { pascalCase } from './support.js'; -import { literalExpression, parseExpression, printStatements, ts } from './ts.js'; +import { codeLiteral } from './ts-literal.js'; import type { DateType } from './types.js'; -const { factory } = ts; +const INDENT = ' '; export type MockOptions = { /** Import specifier for the sdk entry the schema types live in. */ @@ -42,11 +49,11 @@ export type MockOptions = { mockSeed?: number; }; -/** The body expression for `schema` under the active data mode: a static literal tree +/** The body value for `schema` under the active data mode: a static literal tree * (`'static'`) or a tree of `@faker-js/faker` calls (`'faker'`). Both honor `dateType` * and the binary/Blob type demand; the faker path inlines refs with the same cycle * guard as the static sampler, so neither recurses forever on a cyclic schema. */ -function bodyExpression(schema: SchemaModel, model: ApiModel, opts: MockOptions): ts.Expression { +function bodyValue(schema: SchemaModel, model: ApiModel, opts: MockOptions): MockValue { return opts.mockData === 'faker' ? fakerExpression(schema, model.schemas, { dateType: opts.dateType }) : literal(sampleValue(schema, model.schemas, { dateType: opts.dateType })); @@ -56,35 +63,22 @@ function bodyExpression(schema: SchemaModel, model: ApiModel, opts: MockOptions) export function renderMockModule(model: ApiModel, opts: MockOptions): string { const operations = allOperations(model.services); if (operations.length === 0) return ''; - const factories = model.schemas.map((s) => factoryFor(s, model, opts)); - const handlers = operations.flatMap((op) => [ - handlerFor(op, model, opts), - ...(op.errorResponses.length > 0 ? [errorHandlerFor(op, model, opts)] : []), - ]); - const typeImport = schemaTypeImport(model, opts); - // Faker mode imports `faker` (the consumer's dev-dep) and, with a seed, pins it once - // at module top so every run reproduces. Static mode emits neither (stays zero-dep). - const fakerImport = opts.mockData === 'faker' ? "import { faker } from '@faker-js/faker';\n" : ''; - const seed = - opts.mockData === 'faker' && opts.mockSeed !== undefined ? [seedStatement(opts.mockSeed)] : []; - return `import { http, HttpResponse } from 'msw';\n${fakerImport}\n${printStatements([ - ...typeImport, - ...seed, - ...factories, - ...handlers, + const blocks = [ + ...schemaTypeImport(model, opts), + // Faker mode imports `faker` (the consumer's dev-dep) and, with a seed, pins it once + // at module top so every run reproduces. Static mode emits neither (stays zero-dep). + ...(opts.mockData === 'faker' && opts.mockSeed !== undefined + ? [`faker.seed(${opts.mockSeed});`] + : []), + ...model.schemas.map((s) => factoryFor(s, model, opts)), + ...operations.flatMap((op) => [ + handlerFor(op, model, opts), + ...(op.errorResponses.length > 0 ? [errorHandlerFor(op, model, opts)] : []), + ]), handlersArray(operations), - ])}`; -} - -/** `faker.seed();` — pins faker's PRNG so a seeded faker-mode module reproduces. */ -function seedStatement(seed: number): ts.Statement { - return factory.createExpressionStatement( - factory.createCallExpression( - factory.createPropertyAccessExpression(factory.createIdentifier('faker'), 'seed'), - undefined, - [factory.createNumericLiteral(seed)] - ) - ); + ]; + const fakerImport = opts.mockData === 'faker' ? "import { faker } from '@faker-js/faker';\n" : ''; + return `import { http, HttpResponse } from 'msw';\n${fakerImport}\n${blocks.join('\n\n')}`; } /** @@ -94,26 +88,12 @@ function seedStatement(seed: number): ts.Statement { * the schema types also shadows globals of the same name (e.g. an `Error` schema) so the * factory return types resolve to the generated type, not `globalThis.Error`. */ -function schemaTypeImport(model: ApiModel, opts: MockOptions): ts.Statement[] { +function schemaTypeImport(model: ApiModel, opts: MockOptions): string[] { if (model.schemas.length === 0) return []; // Verbatim, not PascalCased: the sdk exports each schema type under its emitted name // (`pet` stays `pet`), and the import must match it exactly. const names = model.schemas.map((s) => s.name).sort(); - return [ - factory.createImportDeclaration( - undefined, - factory.createImportClause( - true, - undefined, - factory.createNamedImports( - names.map((name) => - factory.createImportSpecifier(false, undefined, factory.createIdentifier(name)) - ) - ) - ), - factory.createStringLiteral(opts.sdkModule) - ), - ]; + return [`import type { ${names.join(', ')} } from ${JSON.stringify(opts.sdkModule)};`]; } /** @@ -122,62 +102,36 @@ function schemaTypeImport(model: ApiModel, opts: MockOptions): ts.Statement[] { * to spread into — `Partial` is meaningless and would silently drop the argument — * so its factory takes the FULL type and returns the override wholesale (`overrides ?? sample`). */ -function factoryFor(named: NamedSchemaModel, model: ApiModel, opts: MockOptions): ts.Statement { +function factoryFor(named: NamedSchemaModel, model: ApiModel, opts: MockOptions): string { const pascal = pascalCase(named.name); - const sampled = bodyExpression(named.schema, model, opts); + const sampled = bodyValue(named.schema, model, opts); // Type references use the sdk's verbatim export name; only the factory NAME is PascalCased. - const typeRef = factory.createTypeReferenceNode(named.name); - const spreads = ts.isObjectLiteralExpression(sampled); + const typeName = named.name; + const spreads = isObjectValue(sampled); // Spreading `Partial` (the override type of a union schema) distributes into // `Partial | Partial`, which widens any discriminant property (e.g. `category`) // and defeats narrowing — TS can no longer place the literal in a single union member. // The sampled object is already a complete, correct member, so re-assert the type. + const rendered = renderMockValue(spreads ? spreadInto(sampled, 'overrides') : sampled, INDENT); const body = !spreads - ? factory.createBinaryExpression( - factory.createIdentifier('overrides'), - factory.createToken(ts.SyntaxKind.QuestionQuestionToken), - sampled - ) + ? `overrides ?? ${rendered}` : named.schema.kind === 'union' - ? factory.createAsExpression(spreadOverrides(sampled, 'overrides'), typeRef) - : spreadOverrides(sampled, 'overrides'); - return factory.createFunctionDeclaration( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - undefined, - `create${pascal}`, - undefined, - [ - factory.createParameterDeclaration( - undefined, - undefined, - 'overrides', - factory.createToken(ts.SyntaxKind.QuestionToken), - spreads ? factory.createTypeReferenceNode('Partial', [typeRef]) : typeRef - ), - ], - typeRef, - factory.createBlock([factory.createReturnStatement(body)], true) - ); + ? `${rendered} as ${typeName}` + : rendered; + const overridesType = spreads ? `Partial<${typeName}>` : typeName; + return [ + `export function create${pascal}(overrides?: ${overridesType}): ${typeName} {`, + `${INDENT}return ${body};`, + '}', + ].join('\n'); } /** `export const Handler = (override?: ) => http.('', () => );`. */ -function handlerFor(op: OperationModel, model: ApiModel, opts: MockOptions): ts.Statement { +function handlerFor(op: OperationModel, model: ApiModel, opts: MockOptions): string { const override = overrideParam(op, model, opts); - const arrow = factory.createArrowFunction( - undefined, - undefined, - override ? [override] : [], - undefined, - factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - handlerCall(op, model, opts) - ); - return factory.createVariableStatement( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - factory.createVariableDeclarationList( - [factory.createVariableDeclaration(`${op.name}Handler`, undefined, undefined, arrow)], - ts.NodeFlags.Const - ) - ); + const params = override ?? ''; + const call = `http.${op.method}(${JSON.stringify(mswPath(op.path))}, () => ${responseExpression(op, model, opts)})`; + return `export const ${op.name}Handler = (${params}) => ${call};`; } /** @@ -189,67 +143,12 @@ function handlerFor(op: OperationModel, model: ApiModel, opts: MockOptions): ts. * (plus `number` when a `default` error is present, so any status is allowed). The static * fallback samples the FIRST error response's schema. */ -function errorHandlerFor(op: OperationModel, model: ApiModel, opts: MockOptions): ts.Statement { +function errorHandlerFor(op: OperationModel, model: ApiModel, opts: MockOptions): string { const first = op.errorResponses[0]; - const sampled = bodyExpression(first.schema, model, opts); - const body = factory.createBinaryExpression( - factory.createIdentifier('body'), - factory.createToken(ts.SyntaxKind.QuestionQuestionToken), - sampled - ); - const resolver = factory.createArrowFunction( - undefined, - undefined, - [], - undefined, - factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - factory.createCallExpression( - factory.createPropertyAccessExpression(factory.createIdentifier('HttpResponse'), 'json'), - undefined, - [ - body, - factory.createObjectLiteralExpression( - [factory.createShorthandPropertyAssignment('status')], - false - ), - ] - ) - ); - const call = factory.createCallExpression( - factory.createPropertyAccessExpression(factory.createIdentifier('http'), op.method), - undefined, - [factory.createStringLiteral(mswPath(op.path)), resolver] - ); - const arrow = factory.createArrowFunction( - undefined, - undefined, - [ - factory.createParameterDeclaration( - undefined, - undefined, - 'status', - undefined, - errorStatusType(op) - ), - factory.createParameterDeclaration( - undefined, - undefined, - 'body', - factory.createToken(ts.SyntaxKind.QuestionToken), - errorBodyType(op) - ), - ], - undefined, - factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - call - ); - return factory.createVariableStatement( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - factory.createVariableDeclarationList( - [factory.createVariableDeclaration(`${op.name}ErrorHandler`, undefined, undefined, arrow)], - ts.NodeFlags.Const - ) - ); + const sampled = renderMockValue(bodyValue(first.schema, model, opts), ''); + const resolver = `() => HttpResponse.json(body ?? ${sampled}, { status })`; + const call = `http.${op.method}(${JSON.stringify(mswPath(op.path))}, ${resolver})`; + return `export const ${op.name}ErrorHandler = (status: ${errorStatusType(op)}, body?: ${errorBodyType(op)}) => ${call};`; } /** @@ -257,20 +156,18 @@ function errorHandlerFor(op: OperationModel, model: ApiModel, opts: MockOptions) * of a literal whenever a `default` error or a `4XX`/`5XX` range is present, so any status is * accepted. De-duped, since a multi-media-type error contributes the same status more than once. */ -function errorStatusType(op: OperationModel): ts.TypeNode { +function errorStatusType(op: OperationModel): string { const codes = [ ...new Set( op.errorResponses.filter((r) => typeof r.status === 'number').map((r) => r.status as number) ), ]; - const members: ts.TypeNode[] = codes.map((c) => - factory.createLiteralTypeNode(factory.createNumericLiteral(c)) - ); + const members: string[] = codes.map(String); // A `default` error (or a range wildcard) means any status is valid — widen with `number`. if (op.errorResponses.some((r) => typeof r.status !== 'number')) { - members.push(factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword)); + members.push('number'); } - return members.length === 1 ? members[0] : factory.createUnionTypeNode(members); + return members.join(' | '); } /** @@ -278,18 +175,16 @@ function errorStatusType(op: OperationModel): ts.TypeNode { * named type, anything else to `unknown` (matching how the success handler types its override * loosely). De-duped by printed name. */ -function errorBodyType(op: OperationModel): ts.TypeNode { +function errorBodyType(op: OperationModel): string { const names = new Set(); let hasUnknown = false; for (const r of op.errorResponses) { if (r.schema.kind === 'ref') names.add(r.schema.name); else hasUnknown = true; } - const members: ts.TypeNode[] = [...names].map((n) => factory.createTypeReferenceNode(n)); - if (hasUnknown || members.length === 0) { - members.push(factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword)); - } - return members.length === 1 ? members[0] : factory.createUnionTypeNode(members); + const members = [...names]; + if (hasUnknown || members.length === 0) members.push('unknown'); + return members.join(' | '); } /** @@ -300,52 +195,18 @@ function errorBodyType(op: OperationModel): ts.TypeNode { * `Record`. A body-less or non-object inline response has nothing to * override, so the handler takes no parameter. */ -function overrideParam( - op: OperationModel, - model: ApiModel, - opts: MockOptions -): ts.ParameterDeclaration | undefined { +function overrideParam(op: OperationModel, model: ApiModel, opts: MockOptions): string | undefined { const success = op.successResponses[0]; if (!success || success.schema.kind === 'unknown') return undefined; - let type: ts.TypeNode; if (success.schema.kind === 'ref') { - const typeRef = factory.createTypeReferenceNode(success.schema.name); - type = ts.isObjectLiteralExpression(bodyExpression(success.schema, model, opts)) - ? factory.createTypeReferenceNode('Partial', [typeRef]) - : typeRef; - } else { - if (!ts.isObjectLiteralExpression(bodyExpression(success.schema, model, opts))) { - return undefined; - } - type = factory.createTypeReferenceNode('Record', [ - factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword), - ]); + const typeName = success.schema.name; + const type = isObjectValue(bodyValue(success.schema, model, opts)) + ? `Partial<${typeName}>` + : typeName; + return `override?: ${type}`; } - return factory.createParameterDeclaration( - undefined, - undefined, - 'override', - factory.createToken(ts.SyntaxKind.QuestionToken), - type - ); -} - -/** `http.('', () => )`. */ -function handlerCall(op: OperationModel, model: ApiModel, opts: MockOptions): ts.Expression { - const resolver = factory.createArrowFunction( - undefined, - undefined, - [], - undefined, - factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - responseExpression(op, model, opts) - ); - return factory.createCallExpression( - factory.createPropertyAccessExpression(factory.createIdentifier('http'), op.method), - undefined, - [factory.createStringLiteral(mswPath(op.path)), resolver] - ); + if (!isObjectValue(bodyValue(success.schema, model, opts))) return undefined; + return 'override?: Record'; } /** @@ -356,25 +217,19 @@ function handlerCall(op: OperationModel, model: ApiModel, opts: MockOptions): ts * body-less `new HttpResponse(null, { status })`. The status is the success * response's declared code, or 200 when it's `default`/absent. */ -function responseExpression(op: OperationModel, model: ApiModel, opts: MockOptions): ts.Expression { +function responseExpression(op: OperationModel, model: ApiModel, opts: MockOptions): string { const success = op.successResponses[0]; const status = statusCode(success?.status); - if (!success || success.schema.kind === 'unknown') return emptyResponse(status); + if (!success || success.schema.kind === 'unknown') { + return `new HttpResponse(null, { status: ${status} })`; + } const data = success.schema.kind === 'ref' - ? factory.createCallExpression( - factory.createIdentifier(`create${pascalCase(success.schema.name)}`), - undefined, - [factory.createIdentifier('override')] - ) - : spreadOverrides(bodyExpression(success.schema, model, opts), 'override'); + ? `create${pascalCase(success.schema.name)}(override)` + : renderMockValue(spreadInto(bodyValue(success.schema, model, opts), 'override'), ''); // `HttpResponse.json(x)` already defaults to 200, so only pass `{ status }` when it differs. - const args = status === 200 ? [data] : [data, statusInit(status)]; - return factory.createCallExpression( - factory.createPropertyAccessExpression(factory.createIdentifier('HttpResponse'), 'json'), - undefined, - args - ); + const args = status === 200 ? data : `${data}, { status: ${status} }`; + return `HttpResponse.json(${args})`; } /** Numeric status for a response, mapping `default`/absent to 200. */ @@ -382,50 +237,10 @@ function statusCode(status: ResponseBodyModel['status'] | undefined): number { return typeof status === 'number' ? status : 200; } -/** `{ status: }`. */ -function statusInit(status: number): ts.Expression { - return factory.createObjectLiteralExpression( - [factory.createPropertyAssignment('status', factory.createNumericLiteral(status))], - false - ); -} - -/** `new HttpResponse(null, { status: })`. */ -function emptyResponse(status: number): ts.Expression { - return factory.createNewExpression(factory.createIdentifier('HttpResponse'), undefined, [ - factory.createNull(), - statusInit(status), - ]); -} - /** `export const handlers = [Handler(), …];`. */ -function handlersArray(operations: OperationModel[]): ts.Statement { - const elements = operations.map((op) => - factory.createCallExpression(factory.createIdentifier(`${op.name}Handler`), undefined, []) - ); - return factory.createVariableStatement( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - factory.createVariableDeclarationList( - [ - factory.createVariableDeclaration( - 'handlers', - undefined, - undefined, - factory.createArrayLiteralExpression(elements, false) - ), - ], - ts.NodeFlags.Const - ) - ); -} - -/** Spread `` into an object literal; non-object values pass through unchanged. */ -function spreadOverrides(value: ts.Expression, spreadName: string): ts.Expression { - if (!ts.isObjectLiteralExpression(value)) return value; - return factory.createObjectLiteralExpression( - [...value.properties, factory.createSpreadAssignment(factory.createIdentifier(spreadName))], - true - ); +function handlersArray(operations: OperationModel[]): string { + const elements = operations.map((op) => `${op.name}Handler()`).join(', '); + return `export const handlers = [${elements}];`; } /** `/pets/{petId}` → `*​/pets/:petId` — MSW path with a wildcard origin and `:param` segments. */ @@ -433,25 +248,15 @@ function mswPath(path: string): string { return `*${path.replace(/\{([^{}]+)\}/g, ':$1')}`; } -/** Recursively print a sampled JS value as a TypeScript literal expression. Containers - * stay local rather than delegating to the shared `literalExpression`: sampled trees - * print multiline and may nest a `SampleExpression` at any depth. */ -function literal(value: unknown): ts.Expression { - if (value instanceof SampleExpression) return parseExpression(value.code); - if (Array.isArray(value)) { - return factory.createArrayLiteralExpression(value.map(literal), true); - } +/** Recursively lift a sampled JS value into the render tree. Containers render + * multiline; a `SampleExpression` carries pre-built source (`new Date(...)`). */ +function literal(value: unknown): MockValue { + if (value instanceof SampleExpression) return expr(value.code); + if (Array.isArray(value)) return { kind: 'array', items: value.map(literal) }; if (isPlainObject(value)) { - const entries = Object.entries(value); - return factory.createObjectLiteralExpression( - entries.map(([key, v]) => { - const safe = safeIdent(key); - const name = - safe === key ? factory.createIdentifier(key) : factory.createStringLiteral(key); - return factory.createPropertyAssignment(name, literal(v)); - }), - true + return objectValue( + Object.entries(value).map(([key, entryValue]) => ({ key, value: literal(entryValue) })) ); } - return literalExpression(value); + return expr(codeLiteral(value)); } From 5a332e1207ecbeec5ad0a6e532996e8a78c438c8 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 15:41:49 +0300 Subject: [PATCH 057/211] =?UTF-8?q?feat(client-generator)!:=20remove=20the?= =?UTF-8?q?=20AST=20toolkit=20=E2=80=94=20one=20text-template=20authoring?= =?UTF-8?q?=20model=20everywhere?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/text-template-migration.md | 8 + .../@v2/guides/customize-client-generation.md | 40 +- .../src/__tests__/pipeline-ts-free.test.ts | 7 + .../src/__tests__/plugin.test.ts | 16 +- .../__snapshots__/ts-literal.test.ts.snap | 29 + .../src/emitters/__tests__/descriptor.test.ts | 191 +----- .../emitters/__tests__/render-client.test.ts | 256 -------- .../__tests__/render-descriptors.test.ts | 146 ----- .../__tests__/render-type-guards.test.ts | 80 --- .../src/emitters/__tests__/sse.test.ts | 44 +- .../src/emitters/__tests__/ts-literal.test.ts | 7 +- .../src/emitters/__tests__/ts-type.test.ts | 250 +++----- .../src/emitters/__tests__/types.test.ts | 591 ------------------ .../src/emitters/descriptor.ts | 229 +------ .../client-generator/src/emitters/faker.ts | 9 +- .../src/emitters/operation-aliases.ts | 277 -------- .../src/emitters/operation-types.ts | 147 +---- .../src/emitters/operations.ts | 65 +- packages/client-generator/src/emitters/sse.ts | 12 - .../src/emitters/type-guards.ts | 144 +---- .../client-generator/src/emitters/types.ts | 190 ------ packages/client-generator/src/generate.ts | 26 +- tests/e2e/generate-client/examples/README.md | 52 +- .../examples/ast-toolkit-generator/README.md | 8 +- .../response-map-generator.mjs | 34 +- .../custom-generator/route-map-generator.mjs | 40 +- 26 files changed, 258 insertions(+), 2640 deletions(-) create mode 100644 .changeset/text-template-migration.md create mode 100644 packages/client-generator/src/emitters/__tests__/__snapshots__/ts-literal.test.ts.snap delete mode 100644 packages/client-generator/src/emitters/__tests__/render-client.test.ts delete mode 100644 packages/client-generator/src/emitters/__tests__/render-descriptors.test.ts delete mode 100644 packages/client-generator/src/emitters/__tests__/render-type-guards.test.ts delete mode 100644 packages/client-generator/src/emitters/__tests__/types.test.ts delete mode 100644 packages/client-generator/src/emitters/operation-aliases.ts diff --git a/.changeset/text-template-migration.md b/.changeset/text-template-migration.md new file mode 100644 index 0000000000..f32e90ce37 --- /dev/null +++ b/.changeset/text-template-migration.md @@ -0,0 +1,8 @@ +--- +'@redocly/client-generator': minor +'@redocly/cli': minor +--- + +Every generator — including the TypeScript `sdk` and its satellites — is now authored with source-text templates instead of the TypeScript compiler AST, with byte-identical generated output. Generating a client no longer loads the `typescript` package for any selection (`--setup` baking remains the one lazy exception), and the `@redocly/client-generator/generate` toolkit now exports the text renderers the sdk itself uses (`tsType`, `tsJsdoc`, `codeLiteral`). + +**Note:** the AST exports (`ts`, `printStatements`, `parseStatements`, `printNodes`, `arrow`, `constArray`, `exportConstStatement`, `jsdoc`, `schemaToTypeNode`) were removed from `@redocly/client-generator/generate`, and `schemaToZodExpression` now returns source text instead of a `ts.Expression`. Custom generators built on the AST API should switch to the text toolkit — see the updated `ast-toolkit-generator` example. diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index bde2e23f3b..f4d6631482 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -102,48 +102,32 @@ For a repo-local, agent-readable version of this guidance, copy the [`AGENTS.md` ### TypeScript artifacts -For TypeScript output, build real syntax trees with the emit toolkit from `@redocly/client-generator/generate` — the same `ts.factory` + printer the built-in generators use, so the schema→type mapping matches the sdk's exactly: +For TypeScript output, render types with the text toolkit from `@redocly/client-generator/generate` — `tsType` is the same schema→type renderer the built-in sdk uses, so the mapping (refs, arrays, unions, formats, parenthesization) matches the generated client exactly: -```ts -// response-map-generator.ts -import { defineGenerator } from '@redocly/client-generator'; -import { printStatements, schemaToTypeNode, ts } from '@redocly/client-generator/generate'; - -const { factory } = ts; +```js +import { tsType } from '@redocly/client-generator/generate'; -export default defineGenerator({ +export default { name: 'response-map', requires: ['sdk'], run({ model, outputPath }) { - // One `ResponseShapes` entry per operation with a JSON success body. const members = model.services .flatMap((service) => service.operations) .flatMap((op) => { const success = op.successResponses.find((r) => r.contentType.includes('json')); - if (!success) return []; - return [ - factory.createPropertySignature( - undefined, - op.name, - undefined, - schemaToTypeNode(success.schema) - ), - ]; + return success ? [` ${op.name}: ${tsType(success.schema, 'string', ' ')};`] : []; }); - const alias = factory.createTypeAliasDeclaration( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - 'ResponseShapes', - undefined, - factory.createTypeLiteralNode(members) - ); return [ - { path: outputPath.replace(/\.ts$/, '.responses.ts'), content: printStatements([alias]) }, + { + path: outputPath.replace(/\.ts$/, '.responses.ts'), + content: `export type ResponseShapes = {\n${members.join('\n')}\n};\n`, + }, ]; }, -}); +}; ``` -The toolkit exports `ts`, `printStatements`, `parseStatements`, `operationSignature`, `schemaToTypeNode`, `pascalCase`, and more; the package root exports the model (IR) types. +The toolkit exports `tsType`, `tsJsdoc`, `codeLiteral`, `operationSignature`, `pascalCase`, and more; the package root exports the model (IR) types and the language-neutral helpers. For a trivial artifact, returning a plain string as `content` works too — no toolkit required. Select a generator in `redocly.yaml` by path or package name: @@ -181,7 +165,7 @@ With `codeSamples: true` in the `client` block, generation collects every select The built-in `sdk` generator ships the TypeScript reference implementation, so enabling the flag alone gives your Redoc docs per-operation TypeScript examples that never drift from the SDK. Import-specifier generators execute at generation time — they carry the same trust level as any installed dependency you run. -See the [`ast-toolkit-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/ast-toolkit-generator) for the runnable toolkit-based plugin (including type-importing referenced schemas), the [`custom-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/custom-generator) for a minimal string-building one, and the [`nested-facade` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/nested-facade) for a realistic one that derives an `api..` facade from the description's tags. +See the [`ast-toolkit-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/ast-toolkit-generator) for the runnable `tsType`-based plugin (including type-importing referenced schemas), the [`custom-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/custom-generator) for a minimal string-building one, and the [`nested-facade` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/nested-facade) for a realistic one that derives an `api..` facade from the description's tags. ## Resources 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 02ab77f303..27bb4b546d 100644 --- a/packages/client-generator/src/__tests__/pipeline-ts-free.test.ts +++ b/packages/client-generator/src/__tests__/pipeline-ts-free.test.ts @@ -58,3 +58,10 @@ describe('pipeline (lib/pipeline.js)', () => { expect(emitterFiles).toEqual([]); }); }); + +describe('the sdk generator itself (lib/generators/sdk.js)', () => { + it('loads no typescript — the whole emit path is text templates (setup baking stays lazy)', () => { + const { externals } = staticGraph(join(libDir, 'generators/sdk.js')); + expect(externals.has('typescript')).toBe(false); + }); +}); diff --git a/packages/client-generator/src/__tests__/plugin.test.ts b/packages/client-generator/src/__tests__/plugin.test.ts index d41927f7f8..b16f146ee4 100644 --- a/packages/client-generator/src/__tests__/plugin.test.ts +++ b/packages/client-generator/src/__tests__/plugin.test.ts @@ -1,11 +1,4 @@ -import { - operationSignature, - pascalCase, - printStatements, - safeIdent, - schemaToTypeNode, - ts, -} from '../generate.js'; +import { codeLiteral, operationSignature, pascalCase, safeIdent, tsType } from '../generate.js'; import { type CustomGenerator, defineGenerator } from '../plugin.js'; describe('plugin entry', () => { @@ -14,12 +7,11 @@ describe('plugin entry', () => { expect(defineGenerator(gen)).toBe(gen); }); - it('re-exports the emit toolkit the built-in generators use', () => { + it('re-exports the text toolkit the built-in generators use', () => { // Value re-exports are reachable and usable from the public entry. - expect(typeof ts.factory).toBe('object'); - expect(typeof printStatements).toBe('function'); + expect(tsType({ kind: 'scalar', scalar: 'string' })).toBe('string'); + expect(codeLiteral({ id: 'x' })).toBe('{ id: "x" }'); expect(typeof operationSignature).toBe('function'); - expect(typeof schemaToTypeNode).toBe('function'); expect(pascalCase('pet')).toBe('Pet'); expect(safeIdent('123')).not.toBe('123'); }); diff --git a/packages/client-generator/src/emitters/__tests__/__snapshots__/ts-literal.test.ts.snap b/packages/client-generator/src/emitters/__tests__/__snapshots__/ts-literal.test.ts.snap new file mode 100644 index 0000000000..a40995353c --- /dev/null +++ b/packages/client-generator/src/emitters/__tests__/__snapshots__/ts-literal.test.ts.snap @@ -0,0 +1,29 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`codeLiteral > array 1`] = `"["a", 1, false]"`; + +exports[`codeLiteral > booleans 1`] = `"true"`; + +exports[`codeLiteral > empty array 1`] = `"[]"`; + +exports[`codeLiteral > empty object 1`] = `"{}"`; + +exports[`codeLiteral > flat object 1`] = `"{ id: "getPet", method: "GET", count: 2 }"`; + +exports[`codeLiteral > negative number 1`] = `"-3.5"`; + +exports[`codeLiteral > nested descriptor-like shape 1`] = `"{ id: "listOrders", path: "/orders/{id}", params: [{ name: "id", in: "path" }, { name: "page-size", in: "query", explode: false }], security: [[{ scheme: "Bearer", kind: "bearer" }]], pagination: { style: "cursor", cursorParam: "after", items: "/items" } }"`; + +exports[`codeLiteral > non-identifier key is quoted 1`] = `"{ "X-Request-Id": "header", "a-b": 1 }"`; + +exports[`codeLiteral > null 1`] = `"null"`; + +exports[`codeLiteral > number 1`] = `"42"`; + +exports[`codeLiteral > reserved-word key stays bare 1`] = `"{ in: "query", name: "limit" }"`; + +exports[`codeLiteral > string 1`] = `""plain""`; + +exports[`codeLiteral > string with newline 1`] = `""a\\nb""`; + +exports[`codeLiteral > string with quotes and backslashes 1`] = `""say \\"hi\\" \\\\ done""`; diff --git a/packages/client-generator/src/emitters/__tests__/descriptor.test.ts b/packages/client-generator/src/emitters/__tests__/descriptor.test.ts index a34407db9b..f833a6e8c6 100644 --- a/packages/client-generator/src/emitters/__tests__/descriptor.test.ts +++ b/packages/client-generator/src/emitters/__tests__/descriptor.test.ts @@ -3,14 +3,14 @@ import type { OperationModel, ResponseBodyModel, } from '../../intermediate-representation/model.js'; -import { descriptorStatements, opsInterfaceStatements, packageIdents } from '../descriptor.js'; +import { packageIdents, renderDescriptors } from '../descriptor.js'; import type { EmitContext } from '../operations.js'; import type { ModelPagination } from '../pagination.js'; -import { printStatements } from '../ts.js'; -import { apiModel, modelWith, namedSchema, operation, param, response } from './fixtures.js'; +import { renderOpsType } from '../render-client.js'; +import { apiModel, modelWith, operation, param } from './fixtures.js'; function emitDescriptors(model: ApiModel): string { - return printStatements(descriptorStatements(model, packageIdents(model), 'string')); + return renderDescriptors(model, packageIdents(model), 'string'); } /** A JSON 200 response — keeps `responseKind` at its omitted `'json'` default. */ @@ -55,9 +55,9 @@ describe('packageIdents', () => { }); }); -describe('descriptorStatements', () => { - it('returns no statements for a model with no operations', () => { - expect(descriptorStatements(apiModel(), new Map(), 'string')).toEqual([]); +describe('renderDescriptors', () => { + it('renders nothing for a model with no operations', () => { + expect(renderDescriptors(apiModel(), packageIdents(apiModel()), 'string')).toBe(''); }); it('emits a minimal descriptor with only the non-default fields', () => { @@ -335,132 +335,16 @@ describe('descriptorStatements', () => { }, ], ]); - const out = printStatements( - descriptorStatements(model, packageIdents(model), 'string', pagination) - ); + const out = renderDescriptors(model, packageIdents(model), 'string', pagination); expect(out).toContain( 'pagination: { style: "cursor", param: "cursor", limitParam: "limit", nextCursor: "/nextCursor", items: "/orders" }' ); // Non-paginated entries carry no pagination field. expect(out).toContain('ping: { id: "ping", method: "GET", path: "/ping" }'); }); - - it('emits responseHeaders coerce specs from declared success-response headers', () => { - const out = emitDescriptors( - modelWith([ - operation({ - name: 'listCustomers', - path: '/customers', - successResponses: [ - response({ - schema: { kind: 'array', items: { kind: 'ref', name: 'Customer' } }, - }), - ], - successResponseHeaders: [ - { - name: 'pagination-total', - schema: { kind: 'scalar', scalar: 'integer' }, - required: true, - }, - { name: 'link', schema: { kind: 'scalar', scalar: 'string' } }, - ], - }), - ]) - ); - expect(out).toContain( - 'responseHeaders: [{ name: "pagination-total", key: "paginationTotal", type: "number" }, { name: "link", key: "link", type: "string" }]' - ); - }); - - it('emits safe unique response-header descriptor keys', () => { - const out = emitDescriptors( - modelWith([ - operation({ - name: 'listCustomers', - successResponses: [response()], - successResponseHeaders: [ - { name: '3d-secure', schema: { kind: 'scalar', scalar: 'boolean' } }, - { name: 'x-foo', schema: { kind: 'scalar', scalar: 'integer' } }, - { name: 'x_foo', schema: { kind: 'scalar', scalar: 'string' } }, - ], - }), - ]) - ); - - expect(out).toContain( - 'responseHeaders: [{ name: "3d-secure", key: "_3dSecure", type: "boolean" }, { name: "x-foo", key: "xFoo", type: "number" }, { name: "x_foo", key: "xFoo_2", type: "string" }]' - ); - }); - - it('unwraps nullable header schemas to the inner coerce type', () => { - const out = emitDescriptors( - modelWith([ - operation({ - name: 'listCustomers', - successResponses: [response()], - successResponseHeaders: [ - { - name: 'x-flag', - schema: { - kind: 'union', - members: [{ kind: 'scalar', scalar: 'boolean' }, { kind: 'null' }], - }, - }, - { - name: 'x-count', - schema: { - kind: 'union', - members: [{ kind: 'scalar', scalar: 'integer' }, { kind: 'null' }], - }, - }, - ], - }), - ]) - ); - - expect(out).toContain( - 'responseHeaders: [{ name: "x-flag", key: "xFlag", type: "boolean" }, { name: "x-count", key: "xCount", type: "number" }]' - ); - }); - - it('resolves $ref and allOf wrappers on response-header schemas to the coerce type', () => { - const out = emitDescriptors( - apiModel({ - schemas: [namedSchema('Count', { kind: 'scalar', scalar: 'integer' })], - services: [ - { - name: 'Default', - operations: [ - operation({ - name: 'listCustomers', - successResponses: [response()], - successResponseHeaders: [ - { name: 'x-total', schema: { kind: 'ref', name: 'Count' } }, - { - name: 'x-capped', - schema: { - kind: 'intersection', - members: [ - { kind: 'ref', name: 'Count' }, - { kind: 'unknown', metadata: { minimum: 0 } }, - ], - }, - }, - ], - }), - ], - }, - ], - }) - ); - - expect(out).toContain( - 'responseHeaders: [{ name: "x-total", key: "xTotal", type: "number" }, { name: "x-capped", key: "xCapped", type: "number" }]' - ); - }); }); -describe('opsInterfaceStatements', () => { +describe('renderOpsType', () => { function emitOps(model: ApiModel, extra: Partial = {}): string { const ctx: EmitContext = { argsStyle: 'flat', @@ -469,7 +353,7 @@ describe('opsInterfaceStatements', () => { schemaNames: new Set(), ...extra, }; - return printStatements(opsInterfaceStatements(model, packageIdents(model), ctx)); + return renderOpsType(model, packageIdents(model), ctx); } const getOrder = operation({ @@ -683,7 +567,7 @@ describe('opsInterfaceStatements', () => { // Result mode: `result` is the envelope, so `page` carries the raw page for `.pages()`. const out = emitOps(modelWith([listOrders]), { pagination, errorMode: 'result' }); expect(out).toMatch( - /listOrders: \{\n {8}args: \{\n {12}params\?: ListOrdersParams;\n {8}\};\n {8}result: Result;\n {8}mode: "result";\n {8}item: Order;\n {8}page: ListOrdersResult;\n {4}\};/ + /listOrders: \{\n {8}args: \{\n {12}params\?: ListOrdersParams;\n {8}\};\n {8}result: Result;\n {8}item: Order;\n {8}page: ListOrdersResult;\n {4}\};/ ); // Throw mode emits no page member — `result` already IS the raw page. expect(emitOps(modelWith([listOrders]), { pagination })).not.toContain('page:'); @@ -721,57 +605,4 @@ describe('opsInterfaceStatements', () => { const out = emitOps(modelWith([listOrders]), { pagination, dateType: 'Date' }); expect(out).toContain('item: Date;'); }); - - it('adds a headers member from declared success-response headers', () => { - const out = emitOps( - modelWith([ - operation({ - name: 'listCustomers', - path: '/customers', - successResponses: [ - response({ - schema: { kind: 'array', items: { kind: 'ref', name: 'Customer' } }, - }), - ], - successResponseHeaders: [ - { name: 'pagination-total', schema: { kind: 'scalar', scalar: 'integer' } }, - ], - }), - ]) - ); - expect(out).toContain('headers: {\n paginationTotal?: number;\n };'); - }); - - it('emits safe unique keys, requiredness, and only runtime-supported header types', () => { - const out = emitOps( - modelWith([ - operation({ - name: 'listCustomers', - path: '/customers', - successResponses: [response()], - successResponseHeaders: [ - { - name: '3d-secure', - schema: { kind: 'scalar', scalar: 'boolean' }, - required: true, - }, - { name: 'x-foo', schema: { kind: 'scalar', scalar: 'integer' } }, - { name: 'x_foo', schema: { kind: 'scalar', scalar: 'string' } }, - { - name: 'x-ids', - schema: { - kind: 'array', - items: { kind: 'scalar', scalar: 'integer' }, - }, - }, - ], - }), - ]) - ); - - expect(out).toContain('_3dSecure: boolean;'); - expect(out).toContain('xFoo?: number;'); - expect(out).toContain('xFoo_2?: string;'); - expect(out).toContain('xIds?: string;'); - }); }); diff --git a/packages/client-generator/src/emitters/__tests__/render-client.test.ts b/packages/client-generator/src/emitters/__tests__/render-client.test.ts deleted file mode 100644 index 1da8415faa..0000000000 --- a/packages/client-generator/src/emitters/__tests__/render-client.test.ts +++ /dev/null @@ -1,256 +0,0 @@ -import type { ApiModel, OperationModel } from '../../intermediate-representation/model.js'; -import { opsInterfaceStatements, packageIdents } from '../descriptor.js'; -import { renderOperationAliases, sseAliases } from '../operation-aliases.js'; -import { operationSignature } from '../operation-signature.js'; -import { computeResponse, errorTypeNodes } from '../operation-types.js'; -import type { EmitContext } from '../operations.js'; -import { resolveModelPagination } from '../pagination.js'; -import { renderAliases, renderOpsType } from '../render-client.js'; -import { isSseOp } from '../sse.js'; -import { pascalCase } from '../support.js'; -import { printStatements } from '../ts.js'; - -// Printer-equivalence for the Ops type + `*` alias cluster — the deepest type -// surface of the sdk. The fixture exercises: path/query/header/cookie params with -// JSDoc, required and optional slots, multipart and urlencoded bodies, error -// responses (result mode), SSE with a typed payload, pagination (item/page members), -// alias suppression on schema collisions, and a renamed path-param binding. -const STRING = { kind: 'scalar', scalar: 'string' } as const; -const MODEL = { - title: 'Cafe', - version: '1.0.0', - services: [ - { - name: 'Default', - operations: [ - { - name: 'listOrders', - specName: 'listOrders', - method: 'get', - path: '/orders', - tags: [], - pathParams: [], - queryParams: [ - { - name: 'after', - in: 'query', - required: false, - description: 'Cursor of the page.', - schema: STRING, - }, - { - name: 'page-size', - in: 'query', - required: true, - schema: { kind: 'scalar', scalar: 'integer', metadata: { minimum: 1 } }, - }, - ], - headerParams: [{ name: 'X-Trace', in: 'header', required: false, schema: STRING }], - cookieParams: [{ name: 'session', in: 'cookie', required: true, schema: STRING }], - security: [], - paginationExtension: { - style: 'cursor', - cursorParam: 'after', - nextCursor: '/next', - items: '/items', - }, - successResponses: [ - { - status: '200', - contentType: 'application/json', - schema: { - kind: 'object', - properties: [ - { - name: 'items', - schema: { kind: 'array', items: { kind: 'ref', name: 'Order' } }, - required: true, - }, - { name: 'next', schema: STRING, required: false }, - ], - }, - }, - ], - errorResponses: [ - { - status: '400', - contentType: 'application/json', - schema: { kind: 'ref', name: 'Problem' }, - }, - { - status: '500', - contentType: 'application/json', - schema: { kind: 'ref', name: 'Problem' }, - }, - ], - }, - { - // `params` as a path param forces the `_2` binding rename. - name: 'getOrder', - specName: 'getOrder', - method: 'get', - path: '/orders/{params}', - tags: [], - pathParams: [ - { - name: 'params', - in: 'path', - required: true, - description: 'Order id.', - schema: STRING, - }, - ], - queryParams: [], - headerParams: [], - cookieParams: [], - security: [], - successResponses: [ - { - status: '200', - contentType: 'application/json', - schema: { kind: 'ref', name: 'Order' }, - }, - ], - errorResponses: [], - }, - { - // `SearchResult` schema exists — the `Result` alias is suppressed. - name: 'search', - specName: 'search', - method: 'post', - path: '/search', - tags: [], - pathParams: [], - queryParams: [], - headerParams: [], - cookieParams: [], - security: [], - requestBody: { - contentType: 'application/x-www-form-urlencoded', - required: false, - schema: { kind: 'object', properties: [] }, - }, - successResponses: [ - { - status: '200', - contentType: 'application/json', - schema: { kind: 'ref', name: 'SearchResult' }, - }, - ], - errorResponses: [], - }, - { - name: 'uploadPhoto', - specName: 'uploadPhoto', - method: 'post', - path: '/photos', - tags: [], - pathParams: [], - queryParams: [], - headerParams: [], - cookieParams: [], - security: [], - requestBody: { - contentType: 'multipart/form-data', - required: true, - schema: { - kind: 'object', - properties: [ - { - name: 'photo', - schema: { kind: 'scalar', scalar: 'string', metadata: { format: 'binary' } }, - required: true, - }, - { name: 'caption', schema: STRING, required: false }, - ], - }, - }, - successResponses: [], - errorResponses: [], - }, - { - name: 'streamEvents', - specName: 'streamEvents', - method: 'get', - path: '/events', - tags: [], - pathParams: [], - queryParams: [{ name: 'channel', in: 'query', required: false, schema: STRING }], - headerParams: [], - cookieParams: [], - security: [], - successResponses: [ - { - status: '200', - contentType: 'text/event-stream', - schema: { kind: 'ref', name: 'Order' }, - }, - ], - errorResponses: [], - }, - ], - }, - ], - schemas: [ - { - name: 'Order', - schema: { kind: 'object', properties: [{ name: 'id', schema: STRING, required: true }] }, - }, - { name: 'Problem', schema: { kind: 'object', properties: [] } }, - { name: 'SearchResult', schema: { kind: 'object', properties: [] } }, - ], - securitySchemes: [], -} as unknown as ApiModel; - -function makeCtx(errorMode: 'throw' | 'result'): EmitContext { - return { - argsStyle: 'flat', - errorMode, - dateType: 'string', - schemaNames: new Set(MODEL.schemas.map((s) => s.name)), - pagination: resolveModelPagination(MODEL, undefined), - }; -} - -/** The AST alias cluster exactly as client-assembly builds it (package mode). */ -function astAliases(op: OperationModel, ctx: EmitContext): string { - const { pathParams } = operationSignature(op); - const ordered = pathParams.map((p) => p.param); - const identMap = new Map(pathParams.map((p) => [p.param.name, p.ident])); - if (isSseOp(op)) return printStatements(sseAliases(op, ordered, identMap, ctx, 'wire')); - const { responseType } = computeResponse(op.successResponses, ctx.dateType); - const errorMembers = - ctx.errorMode === 'result' ? errorTypeNodes(op.errorResponses, ctx.dateType) : []; - const errorAlias = errorMembers.length > 0 ? `${pascalCase(op.name)}Error` : ''; - return printStatements( - renderOperationAliases( - op, - responseType, - ordered, - identMap, - errorAlias, - errorMembers, - ctx, - true, - 'wire' - ) - ); -} - -describe.each(['throw', 'result'] as const)('printer equivalence (%s mode)', (errorMode) => { - const ctx = makeCtx(errorMode); - const idents = packageIdents(MODEL); - - it('renderOpsType matches printStatements(opsInterfaceStatements(…))', () => { - expect(renderOpsType(MODEL, idents, ctx)).toBe( - printStatements(opsInterfaceStatements(MODEL, idents, ctx)) - ); - }); - - it.each(MODEL.services[0].operations.map((op) => [op.name, op] as const))( - 'renderAliases(%s) matches the AST alias cluster', - (_name, op) => { - expect(renderAliases(op, ctx, 'wire')).toBe(astAliases(op, ctx)); - } - ); -}); diff --git a/packages/client-generator/src/emitters/__tests__/render-descriptors.test.ts b/packages/client-generator/src/emitters/__tests__/render-descriptors.test.ts deleted file mode 100644 index 49aa769963..0000000000 --- a/packages/client-generator/src/emitters/__tests__/render-descriptors.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import type { ApiModel } from '../../intermediate-representation/model.js'; -import { descriptorStatements, packageIdents, renderDescriptors } from '../descriptor.js'; -import { resolveModelPagination } from '../pagination.js'; -import { printStatements } from '../ts.js'; - -// Equivalence against the AST printer across the descriptor vocabulary: param styles, -// every security kind, multipart bodies, SSE, pagination, tags, and a renamed ident. -const MODEL = { - title: 'Cafe', - version: '1.0.0', - services: [ - { - name: 'Default', - operations: [ - { - name: 'listOrders', - specName: 'listOrders', - method: 'get', - path: '/orders', - tags: ['Orders'], - pathParams: [], - queryParams: [ - { - name: 'after', - in: 'query', - required: false, - schema: { kind: 'scalar', scalar: 'string' }, - }, - { - name: 'filter', - in: 'query', - required: false, - style: 'deepObject', - explode: true, - schema: { kind: 'record', value: { kind: 'scalar', scalar: 'string' } }, - }, - ], - headerParams: [ - { - name: 'X-Trace', - in: 'header', - required: false, - allowReserved: true, - schema: { kind: 'scalar', scalar: 'string' }, - }, - ], - cookieParams: [], - security: [['Bearer'], ['HeaderKey', 'CookieKey']], - paginationExtension: { - style: 'cursor', - cursorParam: 'after', - nextCursor: '/next', - items: '/items', - }, - successResponses: [ - { - status: '200', - contentType: 'application/json', - schema: { - kind: 'object', - properties: [ - { - name: 'items', - schema: { kind: 'array', items: { kind: 'scalar', scalar: 'string' } }, - required: true, - }, - { name: 'next', schema: { kind: 'scalar', scalar: 'string' }, required: false }, - ], - }, - }, - ], - errorResponses: [], - }, - { - // Collides with wiring — packageIdents renames it. - name: 'configure', - specName: 'configure', - method: 'post', - path: '/configure', - tags: [], - pathParams: [], - queryParams: [], - headerParams: [], - cookieParams: [], - security: [['QueryKey']], - requestBody: { - contentType: 'multipart/form-data', - required: true, - schema: { - kind: 'object', - properties: [ - { - name: 'photo', - schema: { kind: 'scalar', scalar: 'string', metadata: { format: 'binary' } }, - required: true, - }, - ], - }, - }, - successResponses: [], - errorResponses: [], - }, - { - name: 'streamEvents', - specName: 'streamEvents', - method: 'get', - path: '/events', - tags: [], - pathParams: [], - queryParams: [], - headerParams: [], - cookieParams: [], - security: [], - successResponses: [ - { - status: '200', - contentType: 'text/event-stream', - schema: { kind: 'object', properties: [] }, - }, - ], - errorResponses: [], - }, - ], - }, - ], - schemas: [], - securitySchemes: [ - { key: 'Bearer', kind: 'bearer' }, - { key: 'HeaderKey', kind: 'apiKeyHeader', headerName: 'X-Key' }, - { key: 'QueryKey', kind: 'apiKeyQuery', paramName: 'api_key' }, - { key: 'CookieKey', kind: 'apiKeyCookie', cookieName: 'sid' }, - ], -} as unknown as ApiModel; - -describe('renderDescriptors matches printStatements(descriptorStatements(…))', () => { - it('full vocabulary, with and without pagination', () => { - const idents = packageIdents(MODEL); - const pagination = resolveModelPagination(MODEL, undefined); - expect(renderDescriptors(MODEL, idents, 'string', pagination)).toBe( - printStatements(descriptorStatements(MODEL, idents, 'string', pagination)) - ); - expect(renderDescriptors(MODEL, idents, 'string')).toBe( - printStatements(descriptorStatements(MODEL, idents, 'string')) - ); - }); -}); diff --git a/packages/client-generator/src/emitters/__tests__/render-type-guards.test.ts b/packages/client-generator/src/emitters/__tests__/render-type-guards.test.ts deleted file mode 100644 index 66117c8aed..0000000000 --- a/packages/client-generator/src/emitters/__tests__/render-type-guards.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { NamedSchemaModel } from '../../intermediate-representation/model.js'; -import { printStatements } from '../ts.js'; -import { renderTypeGuards, typeGuardStatements } from '../type-guards.js'; - -// Printer equivalence across the guard vocabulary: explicit discriminators, -// implicit (shared const property), nested unions, multi-value mappings. -const SCHEMAS: NamedSchemaModel[] = [ - { name: 'Beverage', schema: { kind: 'object', properties: [] } }, - { name: 'Dessert', schema: { kind: 'object', properties: [] } }, - { - name: 'MenuItem', - schema: { - kind: 'union', - members: [ - { kind: 'ref', name: 'Beverage' }, - { kind: 'ref', name: 'Dessert' }, - ], - discriminator: { - propertyName: 'category', - mapping: [ - { value: 'beverage', schemaName: 'Beverage' }, - { value: 'iced-beverage', schemaName: 'Beverage' }, - { value: 'dessert', schemaName: 'Dessert' }, - ], - }, - }, - }, - { - name: 'Ok', - schema: { - kind: 'object', - properties: [{ name: 'status', schema: { kind: 'literal', value: 'ok' }, required: true }], - }, - }, - { - name: 'Failed', - schema: { - kind: 'object', - properties: [ - { name: 'status', schema: { kind: 'literal', value: 'failed' }, required: true }, - ], - }, - }, - { - // Implicit discriminator, nested inside an array property. - name: 'BulkResponse', - schema: { - kind: 'object', - properties: [ - { - name: 'results', - schema: { - kind: 'array', - items: { - kind: 'union', - members: [ - { kind: 'ref', name: 'Ok' }, - { kind: 'ref', name: 'Failed' }, - ], - }, - }, - required: true, - }, - ], - }, - }, -] as unknown as NamedSchemaModel[]; - -describe('renderTypeGuards matches printStatements(typeGuardStatements(…))', () => { - it('explicit + implicit + nested + multi-value mappings', () => { - expect(renderTypeGuards(SCHEMAS)).toBe(printStatements(typeGuardStatements(SCHEMAS))); - }); - - it('no guardable unions renders empty', () => { - const plain: NamedSchemaModel[] = [ - { name: 'Order', schema: { kind: 'object', properties: [] } }, - ] as unknown as NamedSchemaModel[]; - expect(renderTypeGuards(plain)).toBe(''); - }); -}); diff --git a/packages/client-generator/src/emitters/__tests__/sse.test.ts b/packages/client-generator/src/emitters/__tests__/sse.test.ts index fb66c99588..dacea5a0cf 100644 --- a/packages/client-generator/src/emitters/__tests__/sse.test.ts +++ b/packages/client-generator/src/emitters/__tests__/sse.test.ts @@ -1,6 +1,5 @@ import type { ResponseBodyModel, SchemaModel } from '../../intermediate-representation/model.js'; -import { isSseOp, sseDataKind, sseEventType } from '../sse.js'; -import { printNodes } from '../ts.js'; +import { eventSchema, isSseOp, sseDataKind } from '../sse.js'; import { operation } from './fixtures.js'; /** An operation whose success response streams `text/event-stream`. */ @@ -40,37 +39,32 @@ describe('isSseOp', () => { }); }); -describe('sseEventType', () => { - it('uses the per-item schema when present (a ref → a Message reference)', () => { - const out = printNodes([ - sseEventType(sseOp({ itemSchema: { kind: 'ref', name: 'Message' } }), 'string'), - ]); - expect(out).toContain('Message'); +describe('eventSchema (drives the streamed payload type)', () => { + it('uses the per-item schema when present', () => { + expect(eventSchema(sseOp({ itemSchema: { kind: 'ref', name: 'Message' } }))).toEqual({ + kind: 'ref', + name: 'Message', + }); }); it('falls back to the response schema when it is meaningful', () => { - const out = printNodes([ - sseEventType(sseOp({ schema: { kind: 'ref', name: 'Token' } }), 'string'), - ]); - expect(out).toContain('Token'); + expect(eventSchema(sseOp({ schema: { kind: 'ref', name: 'Token' } }))).toEqual({ + kind: 'ref', + name: 'Token', + }); }); it('ignores a typeless `itemSchema` and falls back to the response schema', () => { - const out = printNodes([ - sseEventType( - sseOp({ itemSchema: { kind: 'unknown' }, schema: { kind: 'ref', name: 'Token' } }), - 'string' - ), - ]); - expect(out).toContain('Token'); - }); - - it('falls back to the `string` keyword when no schema is declared', () => { - expect(printNodes([sseEventType(sseOp({}), 'string')])).toBe('string'); + expect( + eventSchema( + sseOp({ itemSchema: { kind: 'unknown' }, schema: { kind: 'ref', name: 'Token' } }) + ) + ).toEqual({ kind: 'ref', name: 'Token' }); }); - it('falls back to `string` when the op is not an SSE op at all', () => { - expect(printNodes([sseEventType(operation({}), 'string')])).toBe('string'); + it('is undefined when no schema is declared (payload types as `string`)', () => { + expect(eventSchema(sseOp({}))).toBeUndefined(); + expect(eventSchema(operation({}))).toBeUndefined(); }); }); diff --git a/packages/client-generator/src/emitters/__tests__/ts-literal.test.ts b/packages/client-generator/src/emitters/__tests__/ts-literal.test.ts index e10ea971ac..03dfe6205e 100644 --- a/packages/client-generator/src/emitters/__tests__/ts-literal.test.ts +++ b/packages/client-generator/src/emitters/__tests__/ts-literal.test.ts @@ -1,7 +1,6 @@ import { codeLiteral } from '../ts-literal.js'; -import { literalExpression, printNodes } from '../ts.js'; -// Equivalence against the AST printer's own output — same bar as ts-type.test.ts. +// 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'], @@ -31,8 +30,8 @@ const CASES: Array<[string, unknown]> = [ ], ]; -describe('codeLiteral matches the AST printer', () => { +describe('codeLiteral', () => { it.each(CASES)('%s', (_label, value) => { - expect(codeLiteral(value)).toBe(printNodes([literalExpression(value)])); + expect(codeLiteral(value)).toMatchSnapshot(); }); }); diff --git a/packages/client-generator/src/emitters/__tests__/ts-type.test.ts b/packages/client-generator/src/emitters/__tests__/ts-type.test.ts index 35fcd265b8..12b48fecae 100644 --- a/packages/client-generator/src/emitters/__tests__/ts-type.test.ts +++ b/packages/client-generator/src/emitters/__tests__/ts-type.test.ts @@ -1,186 +1,110 @@ import type { NamedSchemaModel, SchemaModel } from '../../intermediate-representation/model.js'; import { renderTypeAliases, tsType } from '../ts-type.js'; -import { printStatements } from '../ts.js'; -import { renderSchema, typesStatements, type DateType } from '../types.js'; -// The text renderer replaces the AST printer; while both exist, equivalence is -// asserted against the printer's OWN output across the whole schema vocabulary — -// printer fidelity by construction, so downstream snapshots don't churn per-type. +// Literal expectations for the TS type renderer — the formatting contract every +// generated client's types follow (4-space indent, double quotes, parenthesized +// compound members). The full surface is additionally pinned by the assembly goldens. const STRING: SchemaModel = { kind: 'scalar', scalar: 'string' }; const INT: SchemaModel = { kind: 'scalar', scalar: 'integer' }; -const BOOL: SchemaModel = { kind: 'scalar', scalar: 'boolean' }; -const CASES: Array<[string, SchemaModel, DateType?]> = [ - ['string', STRING], - ['number', { kind: 'scalar', scalar: 'number' }], - ['integer', INT], - ['boolean', BOOL], - ['binary → Blob', { kind: 'scalar', scalar: 'string', metadata: { format: 'binary' } }], - ['date kept as string', { kind: 'scalar', scalar: 'string', metadata: { format: 'date-time' } }], - ['date as Date', { kind: 'scalar', scalar: 'string', metadata: { format: 'date-time' } }, 'Date'], - ['ref', { kind: 'ref', name: 'Order' }], - ['string literal', { kind: 'literal', value: 'fixed' }], - ['number literal', { kind: 'literal', value: 42 }], - ['boolean literal', { kind: 'literal', value: true }], - ['single-value enum', { kind: 'enum', values: ['only'], scalar: 'string' }], - ['string enum', { kind: 'enum', values: ['a', 'b'], scalar: 'string' }], - ['integer enum', { kind: 'enum', values: [1, 2], scalar: 'integer' }], - ['null', { kind: 'null' }], - ['unknown', { kind: 'unknown' }], - ['array of scalar', { kind: 'array', items: STRING }], - [ - 'array of union (parenthesized)', - { kind: 'array', items: { kind: 'union', members: [STRING, { kind: 'null' }] } }, - ], - [ - 'array of multi enum (parenthesized)', - { kind: 'array', items: { kind: 'enum', values: ['a', 'b'], scalar: 'string' } }, - ], - ['array of ref', { kind: 'array', items: { kind: 'ref', name: 'Order' } }], - ['record', { kind: 'record', value: { kind: 'union', members: [STRING, INT] } }], - ['empty object', { kind: 'object', properties: [] }], - [ - 'object with the full property vocabulary', - { +describe('tsType', () => { + it.each<[string, SchemaModel, string]>([ + ['scalars', INT, 'number'], + ['binary → Blob', { kind: 'scalar', scalar: 'string', metadata: { format: 'binary' } }, 'Blob'], + ['ref', { kind: 'ref', name: 'Order' }, 'Order'], + ['literal', { kind: 'literal', value: 'fixed' }, '"fixed"'], + ['enum', { kind: 'enum', values: ['a', 'b'], scalar: 'string' }, '"a" | "b"'], + ['array of ref', { kind: 'array', items: { kind: 'ref', name: 'Order' } }, 'Order[]'], + [ + 'array of union (parenthesized)', + { kind: 'array', items: { kind: 'union', members: [STRING, { kind: 'null' }] } }, + '(string | null)[]', + ], + [ + 'nullable enum (the OAS 3.1 shape, parenthesized)', + { + kind: 'union', + members: [ + { kind: 'enum', values: ['active', 'archived'], scalar: 'string' }, + { kind: 'null' }, + ], + }, + '("active" | "archived") | null', + ], + ['record', { kind: 'record', value: STRING }, 'Record'], + ['omit', { kind: 'omit', base: 'Pet', keys: ['id'] }, 'Omit'], + [ + 'intersection with parenthesized union member', + { + kind: 'intersection', + members: [ + { kind: 'ref', name: 'Base' }, + { kind: 'union', members: [STRING, INT] }, + ], + }, + 'Base & (string | number)', + ], + ['empty object', { kind: 'object', properties: [] }, '{}'], + ])('%s', (_label, schema, expected) => { + expect(tsType(schema)).toBe(expected); + }); + + it('renders objects multiline with JSDoc, readonly, optional, and quoted keys', () => { + const schema: SchemaModel = { kind: 'object', properties: [ { name: 'id', schema: STRING, required: true, readOnly: true }, - { - name: 'note', - schema: STRING, - required: false, - description: 'Free-form note.\nSecond line.', - }, + { name: 'note', schema: STRING, required: false, description: 'Free-form note.' }, { name: 'weird-name', schema: INT, required: true }, - { - name: 'limit', - schema: { kind: 'scalar', scalar: 'integer', metadata: { minimum: 1, maximum: 100 } }, - required: false, - }, - { - name: 'nested', - schema: { - kind: 'object', - properties: [{ name: 'deep', schema: BOOL, required: false }], - }, - required: true, - }, - ], - }, - ], - [ - 'union with object member', - { - kind: 'union', - members: [ - { kind: 'object', properties: [{ name: 'a', schema: STRING, required: true }] }, - { kind: 'null' }, - ], - }, - ], - [ - 'intersection with union member (parenthesized)', - { - kind: 'intersection', - members: [ - { kind: 'ref', name: 'Base' }, - { kind: 'union', members: [STRING, INT] }, - ], - }, - ], - [ - 'multi enum inside a union (parenthesized) — the OAS 3.1 nullable-enum shape', - { - kind: 'union', - members: [ - { kind: 'enum', values: ['active', 'archived'], scalar: 'string' }, - { kind: 'null' }, - ], - }, - ], - [ - 'union inside a union (parenthesized)', - { - kind: 'union', - members: [{ kind: 'union', members: [STRING, { kind: 'null' }] }, INT], - }, - ], - [ - 'intersection inside a union (parenthesized)', - { - kind: 'union', - members: [ - { - kind: 'intersection', - members: [ - { kind: 'ref', name: 'A' }, - { kind: 'ref', name: 'B' }, - ], - }, - { kind: 'null' }, ], - }, - ], - [ - 'single-value enum inside a union stays bare', - { - kind: 'union', - members: [{ kind: 'enum', values: ['only'], scalar: 'string' }, { kind: 'null' }], - }, - ], - ['omit', { kind: 'omit', base: 'Pet', keys: ['id', 'createdAt'] }], -]; + }; + expect(tsType(schema)).toBe( + [ + '{', + ' readonly id: string;', + ' /**', + ' * Free-form note.', + ' */', + ' note?: string;', + ' "weird-name": number;', + '}', + ].join('\n') + ); + }); -describe('tsType matches the AST printer for every schema shape', () => { - it.each(CASES)('%s', (_label, schema, dateType) => { - expect(tsType(schema, dateType ?? 'string')).toBe(renderSchema(schema, dateType ?? 'string')); + it('under dateType Date, date-formatted strings become Date', () => { + const schema: SchemaModel = { + kind: 'scalar', + scalar: 'string', + metadata: { format: 'date-time' }, + }; + expect(tsType(schema, 'Date')).toBe('Date'); + expect(tsType(schema, 'string')).toBe('string'); }); }); -describe('renderTypeAliases matches printStatements(typesStatements(…))', () => { - it('aliases with JSDoc, enum const companions, and quoted-value enums', () => { +describe('renderTypeAliases', () => { + it('emits aliases with JSDoc and identifier-safe enum const companions', () => { const schemas: NamedSchemaModel[] = [ + { name: 'Status', schema: { kind: 'enum', values: ['open', 'closed'], scalar: 'string' } }, { - name: 'Status', - schema: { kind: 'enum', values: ['open', 'closed'], scalar: 'string' }, - }, - { - name: 'Scopes', // `menu:read` is not a valid identifier — no const companion. - schema: { kind: 'enum', values: ['menu:read', 'menu:write'], scalar: 'string' }, - }, - { - name: 'Order', - schema: { - kind: 'object', - description: 'One placed order.', - metadata: { deprecated: true }, - properties: [{ name: 'id', schema: STRING, required: true }], - }, - }, - { - name: 'Page', - schema: { - kind: 'intersection', - members: [ - { kind: 'ref', name: 'Base' }, - { - kind: 'object', - properties: [ - { - name: 'items', - schema: { kind: 'array', items: { kind: 'ref', name: 'Order' } }, - required: true, - }, - ], - }, - ], - }, + name: 'Scopes', + schema: { kind: 'enum', values: ['menu:read'], scalar: 'string' }, }, - ]; - expect(renderTypeAliases(schemas, 'string')).toBe( - printStatements(typesStatements(schemas, 'string')) + ] as NamedSchemaModel[]; + expect(renderTypeAliases(schemas)).toBe( + [ + 'export type Status = "open" | "closed";', + '', + 'export const Status = {', + ' open: "open",', + ' closed: "closed"', + '} as const;', + '', + 'export type Scopes = "menu:read";', + ].join('\n') ); }); }); diff --git a/packages/client-generator/src/emitters/__tests__/types.test.ts b/packages/client-generator/src/emitters/__tests__/types.test.ts deleted file mode 100644 index 17126026e3..0000000000 --- a/packages/client-generator/src/emitters/__tests__/types.test.ts +++ /dev/null @@ -1,591 +0,0 @@ -import type { PropertyModel, SchemaModel } from '../../intermediate-representation/model.js'; -import { emitClientSingleFile } from '../client-assembly.js'; -import { printNodes } from '../ts.js'; -import { renderSchema, schemaToTypeNode, typesStatements } from '../types.js'; -import { SCALAR, apiModel, namedSchema } from './fixtures.js'; - -// The package arm keeps the emitted text free of the embedded runtime, so the -// absence assertions below test the schema types/guards alone. -const emitPackage: typeof emitClientSingleFile = (model, options = {}) => - emitClientSingleFile(model, { ...options, runtime: 'package' }); - -describe('renderTypes', () => { - it('produces nothing when there are no schemas', () => { - const out = emitPackage(apiModel({ schemas: [] })); - // Two consecutive blank lines would be a sign of an empty types block; check absence. - expect(out).not.toContain('export type T'); - }); - - it('emits each named schema with its description', () => { - const out = emitPackage( - apiModel({ - schemas: [namedSchema('Foo', { kind: 'scalar', scalar: 'string' }, 'a foo')], - }) - ); - expect(out).toContain('/**\n * a foo\n */'); - expect(out).toContain('export type Foo = string;'); - }); - - it('prefers schema description over the named-schema description', () => { - const out = emitPackage( - apiModel({ - schemas: [ - namedSchema('Foo', { kind: 'scalar', scalar: 'string', description: 'inner' }, 'outer'), - ], - }) - ); - expect(out).toContain('/**\n * inner\n */'); - expect(out).not.toContain('outer'); - }); - - it('omits JSDoc when the schema description is whitespace-only', () => { - // Exercises the `!text.trim()` short-circuit inside renderJsDoc. - const out = emitPackage( - apiModel({ - schemas: [namedSchema('Foo', { kind: 'scalar', scalar: 'string' }, ' ')], - }) - ); - expect(out).not.toContain('/** '); - expect(out).toContain('export type Foo = string;'); - }); - - it('trims leading and trailing blank lines from multi-line schema descriptions', () => { - // Exercises both `start++` and `end--` arms of trimLines. - const out = emitPackage( - apiModel({ - schemas: [ - namedSchema('Foo', { - kind: 'scalar', - scalar: 'string', - description: '\n\nfirst\nsecond\n\n', - }), - ], - }) - ); - expect(out).toContain('/**\n * first\n * second\n */'); - expect(out).not.toMatch(/\/\*\*\n \*\n/); - }); -}); - -describe('renderSchema (and its branches)', () => { - it('renders scalars: string/number/integer/boolean', () => { - expect(renderSchema({ kind: 'scalar', scalar: 'string' })).toBe('string'); - expect(renderSchema({ kind: 'scalar', scalar: 'number' })).toBe('number'); - expect(renderSchema({ kind: 'scalar', scalar: 'integer' })).toBe('number'); - expect(renderSchema({ kind: 'scalar', scalar: 'boolean' })).toBe('boolean'); - }); - - it('renders a ref as the bare name', () => { - expect(renderSchema({ kind: 'ref', name: 'Foo' })).toBe('Foo'); - }); - - it('renders a binary-format string as Blob (file/upload content)', () => { - expect(renderSchema({ kind: 'scalar', scalar: 'string', metadata: { format: 'binary' } })).toBe( - 'Blob' - ); - // `byte` (base64) stays a string; only `binary` is raw content. - expect(renderSchema({ kind: 'scalar', scalar: 'string', metadata: { format: 'byte' } })).toBe( - 'string' - ); - }); - - it('renders string/number/boolean literals correctly', () => { - expect(renderSchema({ kind: 'literal', value: 'hi' })).toBe('"hi"'); - expect(renderSchema({ kind: 'literal', value: 42 })).toBe('42'); - expect(renderSchema({ kind: 'literal', value: true })).toBe('true'); - expect(renderSchema({ kind: 'literal', value: false })).toBe('false'); - }); - - it('renders negative number literals (built via a prefix-minus expression)', () => { - // TypeScript's factory rejects a bare negative numeric literal — it must be a - // unary-minus over a positive literal, which this exercises. - expect(renderSchema({ kind: 'literal', value: -5 })).toBe('-5'); - expect(renderSchema({ kind: 'enum', values: [-1, 2], scalar: 'number' })).toBe('-1 | 2'); - }); - - it('renders single-value enums without parens when wrapped in array (parens=true)', () => { - expect( - renderSchema({ - kind: 'array', - items: { kind: 'enum', values: ['a'], scalar: 'string' }, - }) - ).toBe('"a"[]'); - }); - - it('renders multi-value enums WITH parens when wrapped in array (parens=true)', () => { - expect( - renderSchema({ - kind: 'array', - items: { kind: 'enum', values: ['a', 'b'], scalar: 'string' }, - }) - ).toBe('("a" | "b")[]'); - }); - - it('renders number enums without JSON-quoting them', () => { - expect(renderSchema({ kind: 'enum', values: [1, 2, 3], scalar: 'number' })).toBe('1 | 2 | 3'); - }); - - it('renders boolean enums without JSON-quoting them', () => { - expect(renderSchema({ kind: 'enum', values: [true, false], scalar: 'boolean' })).toBe( - 'true | false' - ); - }); - - it('renders null and unknown', () => { - expect(renderSchema({ kind: 'null' })).toBe('null'); - expect(renderSchema({ kind: 'unknown' })).toBe('unknown'); - }); - - it('renders array of scalars', () => { - expect(renderSchema({ kind: 'array', items: SCALAR })).toBe('string[]'); - }); - - it('parenthesizes unions inside arrays', () => { - expect( - renderSchema({ - kind: 'array', - items: { kind: 'union', members: [SCALAR, { kind: 'null' }] }, - }) - ).toBe('(string | null)[]'); - }); - - it('renders records', () => { - expect(renderSchema({ kind: 'record', value: SCALAR })).toBe('Record'); - }); - - it('renders empty objects as `{}`', () => { - expect(renderSchema({ kind: 'object', properties: [] })).toBe('{}'); - }); - - it('renders required vs optional properties', () => { - const props: PropertyModel[] = [ - { name: 'id', schema: SCALAR, required: true }, - { name: 'name', schema: SCALAR, required: false }, - ]; - const got = renderSchema({ kind: 'object', properties: props }); - expect(got).toContain('id: string;'); - expect(got).toContain('name?: string;'); - }); - - it('renders an inline single-line JSDoc above a property with a short description', () => { - const got = renderSchema({ - kind: 'object', - properties: [{ name: 'a', schema: SCALAR, required: true, description: 'short' }], - }); - expect(got).toContain(' /**\n * short\n */\n a: string;'); - }); - - it('renders an inline multi-line JSDoc above a property with a long description', () => { - const got = renderSchema({ - kind: 'object', - properties: [ - { - name: 'a', - schema: SCALAR, - required: true, - description: 'line1\nline2', - }, - ], - }); - expect(got).toContain(' /**\n * line1\n * line2\n */\n'); - }); - - it('emits a `readonly` modifier on readOnly properties', () => { - // readOnly (server-managed) props are marked `readonly` so consumer write-type - // utilities (e.g. OmitReadOnly) can strip them; non-readOnly props are plain. - const got = renderSchema({ - kind: 'object', - properties: [ - { name: 'id', schema: SCALAR, required: true, readOnly: true }, - { name: 'name', schema: SCALAR, required: true }, - ], - }); - expect(got).toContain('readonly id: string;'); - expect(got).toMatch(/\n {4}name: string;/); - expect(got).not.toContain('readonly name'); - }); - - it('renders an omit schema as Omit', () => { - expect(renderSchema({ kind: 'omit', base: 'Pet', keys: ['id', 'createdAt'] })).toBe( - 'Omit' - ); - }); - - it('renders union and intersection', () => { - expect(renderSchema({ kind: 'union', members: [SCALAR, { kind: 'null' }] })).toBe( - 'string | null' - ); - const inter = renderSchema({ - kind: 'intersection', - members: [ - { kind: 'ref', name: 'A' }, - { kind: 'ref', name: 'B' }, - ], - }); - expect(inter).toBe('A & B'); - }); - - it('quotes property names that contain disallowed characters', () => { - const got = renderSchema({ - kind: 'object', - properties: [{ name: 'menu:read', schema: SCALAR, required: true }], - }); - expect(got).toContain('"menu:read": string;'); - }); - - it('quotes property names that are reserved words', () => { - const got = renderSchema({ - kind: 'object', - properties: [{ name: 'class', schema: SCALAR, required: true }], - }); - expect(got).toContain('"class": string;'); - }); - - it('renders an inline empty-description JSDoc as nothing', () => { - const got = renderSchema({ - kind: 'object', - properties: [{ name: 'a', schema: SCALAR, required: true, description: ' ' }], - }); - // empty description ⇒ no JSDoc and no leading 2-space indent before the prop - expect(got).not.toContain('/**'); - expect(got).toContain(' a: string;'); - }); -}); - -describe('JSDoc validation metadata (@minimum / @maxLength / @pattern / @format / @deprecated)', () => { - it('renders numeric constraints as JSDoc tags on a named schema', () => { - const out = emitPackage( - apiModel({ - schemas: [ - namedSchema('Limit', { - kind: 'scalar', - scalar: 'integer', - metadata: { minimum: 1, maximum: 100 }, - }), - ], - }) - ); - expect(out).toMatch( - /\/\*\*[\s\S]*@minimum 1[\s\S]*@maximum 100[\s\S]*\*\/\s*export type Limit = number;/ - ); - }); - - it('renders string constraints (minLength, maxLength, pattern, format) as JSDoc tags', () => { - const out = emitPackage( - apiModel({ - schemas: [ - namedSchema('Name', { - kind: 'scalar', - scalar: 'string', - metadata: { - minLength: 1, - maxLength: 50, - pattern: '^[A-Z]+$', - format: 'email', - }, - }), - ], - }) - ); - expect(out).toContain('@minLength 1'); - expect(out).toContain('@maxLength 50'); - expect(out).toContain('@pattern ^[A-Z]+$'); - expect(out).toContain('@format email'); - }); - - it('renders array constraints (minItems, maxItems, uniqueItems) as JSDoc tags', () => { - const out = emitPackage( - apiModel({ - schemas: [ - namedSchema('Tags', { - kind: 'array', - items: { kind: 'scalar', scalar: 'string' }, - metadata: { minItems: 1, maxItems: 5, uniqueItems: true }, - }), - ], - }) - ); - expect(out).toContain('@minItems 1'); - expect(out).toContain('@maxItems 5'); - expect(out).toContain('@uniqueItems'); - // No value after @uniqueItems — it's a presence-only tag. - expect(out).not.toContain('@uniqueItems true'); - }); - - it('renders @exclusiveMinimum / @exclusiveMaximum in numeric form', () => { - const out = emitPackage( - apiModel({ - schemas: [ - namedSchema('Volume', { - kind: 'scalar', - scalar: 'number', - metadata: { exclusiveMinimum: 0, exclusiveMaximum: 1000 }, - }), - ], - }) - ); - expect(out).toContain('@exclusiveMinimum 0'); - expect(out).toContain('@exclusiveMaximum 1000'); - }); - - it('renders @deprecated as a presence-only tag', () => { - const out = emitPackage( - apiModel({ - schemas: [ - namedSchema('Old', { - kind: 'scalar', - scalar: 'string', - metadata: { deprecated: true }, - }), - ], - }) - ); - expect(out).toContain('@deprecated'); - expect(out).not.toContain('@deprecated true'); - }); - - it('combines description text and tags in the same JSDoc block', () => { - const out = emitPackage( - apiModel({ - schemas: [ - namedSchema('Limit', { - kind: 'scalar', - scalar: 'integer', - description: 'Page size.', - metadata: { minimum: 1, maximum: 100 }, - }), - ], - }) - ); - // Description first, then tag lines. - expect(out).toMatch(/\*\s*Page size\.\s*\n\s*\*\s*@minimum 1\s*\n\s*\*\s*@maximum 100/); - }); - - it('renders metadata above inline object properties', () => { - const got = renderSchema({ - kind: 'object', - properties: [ - { - name: 'name', - required: true, - description: 'Display name.', - schema: { - kind: 'scalar', - scalar: 'string', - metadata: { minLength: 1, maxLength: 50, pattern: '^[A-Z]+$' }, - }, - }, - ], - }); - // Multi-line JSDoc with description then tags, immediately above the prop line. - expect(got).toMatch(/\* Display name\./); - expect(got).toMatch(/\* @minLength 1/); - expect(got).toMatch(/\* @maxLength 50/); - expect(got).toMatch(/\* @pattern \^\[A-Z\]\+\$/); - expect(got).toContain('name: string;'); - }); - - it('omits the JSDoc block when there is neither description nor metadata', () => { - const got = renderSchema({ - kind: 'object', - properties: [{ name: 'a', schema: SCALAR, required: true }], - }); - expect(got).not.toContain('/**'); - }); - - it('emits a JSDoc block when metadata exists even without a description', () => { - const got = renderSchema({ - kind: 'object', - properties: [ - { - name: 'page', - required: true, - schema: { - kind: 'scalar', - scalar: 'integer', - metadata: { minimum: 1 }, - }, - }, - ], - }); - expect(got).toMatch(/\/\*\*\n {5}\* @minimum 1\n {5}\*\/\n {4}page: number;/); - }); - - it('escapes `*/` inside pattern strings so it cannot terminate the JSDoc block', () => { - // Defensive guard: a regex pattern of `^a*/b$` would otherwise break the comment. - const out = emitPackage( - apiModel({ - schemas: [ - namedSchema('Tricky', { - kind: 'scalar', - scalar: 'string', - metadata: { pattern: '^a*/b$' }, - }), - ], - }) - ); - expect(out).toContain('@pattern ^a*\\/b$'); - expect(out).not.toContain('@pattern ^a*/b$'); - }); - - it('does not emit a JSDoc block when metadata bag is present but empty', () => { - // We never produce `{}` from the builder, but harden the renderer anyway. - const got = renderSchema({ - kind: 'object', - properties: [{ name: 'a', schema: { ...SCALAR, metadata: {} }, required: true }], - }); - expect(got).not.toContain('/**'); - }); -}); - -describe('dateType knob (string → Date for date formats)', () => { - const dateTime = (): SchemaModel => ({ - kind: 'scalar', - scalar: 'string', - metadata: { format: 'date-time' }, - }); - - it('emits Date for a date-time string scalar under dateType "Date"', () => { - expect(renderSchema(dateTime(), 'Date')).toBe('Date'); - }); - - it('emits Date for a date string scalar under dateType "Date"', () => { - expect( - renderSchema({ kind: 'scalar', scalar: 'string', metadata: { format: 'date' } }, 'Date') - ).toBe('Date'); - }); - - it('keeps string for date-time under dateType "string"', () => { - expect(renderSchema(dateTime(), 'string')).toBe('string'); - }); - - it('keeps string for date-time by default (omitted dateType — byte-identical)', () => { - expect(renderSchema(dateTime())).toBe('string'); - }); - - it('keeps string for a non-date string format regardless of dateType', () => { - const email: SchemaModel = { kind: 'scalar', scalar: 'string', metadata: { format: 'email' } }; - expect(renderSchema(email, 'Date')).toBe('string'); - }); - - it('leaves non-string scalars unaffected under dateType "Date"', () => { - expect(renderSchema({ kind: 'scalar', scalar: 'integer' }, 'Date')).toBe('number'); - }); - - it('threads Date into nested object properties and arrays under "Date"', () => { - const out = renderSchema( - { - kind: 'object', - properties: [ - { name: 'createdAt', schema: dateTime(), required: true }, - { name: 'days', schema: { kind: 'array', items: dateTime() }, required: false }, - ], - }, - 'Date' - ); - expect(out).toContain('createdAt: Date;'); - expect(out).toContain('days?: Date[];'); - }); - - it('emits Date in the named-schema alias body under emitOptions dateType "Date"', () => { - const out = emitPackage(apiModel({ schemas: [namedSchema('Created', dateTime())] }), { - dateType: 'Date', - }); - expect(out).toContain('export type Created = Date;'); - }); - - it('leaves the named-schema alias as string by default', () => { - const out = emitPackage(apiModel({ schemas: [namedSchema('Created', dateTime())] })); - expect(out).toContain('export type Created = string;'); - }); - - it('defaults schemaToTypeNode dateType to string (called with one arg)', () => { - expect(printNodes([schemaToTypeNode(dateTime())])).toBe('string'); - }); - - it('defaults typesStatements dateType to string (called without it)', () => { - const out = printNodes(typesStatements([namedSchema('Created', dateTime())])); - expect(out).toContain('export type Created = string;'); - }); -}); - -describe('enum style — const-object companion (C6.2)', () => { - const orderStatus = namedSchema('OrderStatus', { - kind: 'enum', - scalar: 'string', - values: ['placed', 'completed'], - }); - - it('emits a const-object companion for named string enums by default', () => { - const out = emitPackage(apiModel({ schemas: [orderStatus] })); - expect(out).toContain('export type OrderStatus = "placed" | "completed";'); - expect(out).toContain('export const OrderStatus = {'); - expect(out).toContain('placed: "placed",'); - expect(out).toContain('completed: "completed"'); - expect(out).toContain('} as const;'); - }); - - it('does not emit a const object for integer enums', () => { - const out = emitPackage( - apiModel({ - schemas: [ - namedSchema('Code', { - kind: 'enum', - scalar: 'integer', - values: [1, 2], - }), - ], - }) - ); - expect(out).toContain('export type Code = 1 | 2;'); - expect(out).not.toContain('export const Code'); - }); - - it('does not emit a const object for boolean enums', () => { - const out = emitPackage( - apiModel({ - schemas: [ - namedSchema('Flag', { - kind: 'enum', - scalar: 'boolean', - values: [true, false], - }), - ], - }) - ); - expect(out).not.toContain('export const Flag'); - }); - - it('skips the const object when any value is not a valid identifier', () => { - const out = emitPackage( - apiModel({ - schemas: [ - namedSchema('Scope', { - kind: 'enum', - scalar: 'string', - values: ['menu:read', 'menuWrite'], - }), - ], - }) - ); - expect(out).toContain('export type Scope = "menu:read" | "menuWrite";'); - expect(out).not.toContain('export const Scope'); - }); - - it('skips the const object for a string-scalar enum that contains a non-string value', () => { - // scalarForEnumValues can return 'string' for a mixed enum; the const-object - // path must still bail when a value isn't actually a string. - const out = emitPackage( - apiModel({ - schemas: [ - namedSchema('Mixed', { - kind: 'enum', - scalar: 'string', - values: ['a', 1], - }), - ], - }) - ); - expect(out).not.toContain('export const Mixed'); - }); -}); diff --git a/packages/client-generator/src/emitters/descriptor.ts b/packages/client-generator/src/emitters/descriptor.ts index 7c021f79c7..b248ea298b 100644 --- a/packages/client-generator/src/emitters/descriptor.ts +++ b/packages/client-generator/src/emitters/descriptor.ts @@ -1,33 +1,25 @@ // Package-mode descriptor emission: the identifier plan for a generated module that // shares scope with the `@redocly/client-generator` wiring, plus the `OPERATIONS` // descriptor map (`satisfies Record` — the semver skew -// guard against the runtime contract in src/runtime/types.ts). +// guard against the runtime contract in src/runtime/types.ts). Text templates. import { allOperations, type ApiModel, - type NamedSchemaModel, type OperationModel, type SecuritySchemeModel, } from '../intermediate-representation/model.js'; import type { SecuritySpec } from '../runtime/types.js'; import { authSetterNames } from './auth.js'; import { uniqueIdent } from './identifier.js'; -import { variablesTypeLiteral } from './operation-aliases.js'; -import { operationSignature } from './operation-signature.js'; -import { computeResponse, errorTypeNodes, isTypedMultipart } from './operation-types.js'; -import type { EmitContext } from './operations.js'; +import { isTypedMultipart } from './operation-types.js'; import type { ModelPagination } from './pagination.js'; +import { responseText } from './render-client.js'; import { WIRING_NAMES } from './reserved-names.js'; -import { responseHeadersTypeLiteral, responseHeaderSpecs } from './response-headers.js'; -import { isSseOp, sseDataKind, sseEventType } from './sse.js'; -import { pascalCase } from './support.js'; +import { isSseOp, sseDataKind } from './sse.js'; import { codeLiteral } from './ts-literal.js'; import { tsJsdoc } from './ts-type.js'; -import { jsdoc, literalExpression, parseStatements, ts } from './ts.js'; -import { type DateType, schemaToTypeNode } from './types.js'; - -const { factory } = ts; +import type { DateType } from './types.js'; /** * Operation-name → emitted-identifier plan. The full reserved set (wiring + imported @@ -47,8 +39,7 @@ function descriptorValue( op: OperationModel, schemes: SecuritySchemeModel[], dateType: DateType, - pagination?: ModelPagination, - schemas: readonly NamedSchemaModel[] = [] + pagination?: ModelPagination ) { const params = [...op.pathParams, ...op.queryParams, ...op.headerParams, ...op.cookieParams].map( (p) => ({ @@ -75,8 +66,7 @@ function descriptorValue( .map((alternative) => alternative.flatMap(toSpecs)) .filter((alternative) => alternative.length > 0); const sse = isSseOp(op); - const responseKind = sse ? 'sse' : computeResponse(op.successResponses, dateType).responseKind; - const responseHeaders = responseHeaderSpecs(op.successResponseHeaders, schemas); + const responseKind = sse ? 'sse' : responseText(op.successResponses, dateType).kind; return { // The spec's operationId, NOT the (possibly renamed) map key: `id` drives middleware // targeting (`ctx.operation.id`) and must match inline mode's `operationMetaExpr`. @@ -100,72 +90,10 @@ function descriptorValue( ...(security.length > 0 ? { security } : {}), // The resolved spec is already normalized with stable key order (see pagination.ts). ...(pagination?.has(op.name) ? { pagination: pagination.get(op.name)!.spec } : {}), - ...(responseHeaders === undefined ? {} : { responseHeaders }), }; } -/** `export const OPERATIONS = {…} as const satisfies Record;` + unions. */ -export function descriptorStatements( - model: ApiModel, - idents: Map, - dateType: DateType, - pagination?: ModelPagination -): ts.Statement[] { - const ops = allOperations(model.services); - if (ops.length === 0) return []; - const entries = ops.map((op) => - factory.createPropertyAssignment( - idents.get(op.name)!, - literalExpression( - descriptorValue(op, model.securitySchemes, dateType, pagination, model.schemas) - ) - ) - ); - const operations = jsdoc( - factory.createVariableStatement( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - factory.createVariableDeclarationList( - [ - factory.createVariableDeclaration( - 'OPERATIONS', - undefined, - undefined, - factory.createSatisfiesExpression( - factory.createAsExpression( - factory.createObjectLiteralExpression(entries, true), - factory.createTypeReferenceNode('const') - ), - factory.createTypeReferenceNode('Record', [ - factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - factory.createTypeReferenceNode('OperationDescriptor'), - ]) - ) - ), - ], - ts.NodeFlags.Const - ) - ), - 'The wire-shape descriptor for every operation, keyed by operationId — the data the\n' + - 'runtime routes requests by. Also minification-safe static metadata (method, path,\n' + - 'tags) for cache keys, tracing span names, and request logging.' - ); - // `tags` is present only on tagged entries, so `OperationTag` is derived via `Extract` - // (a plain `["tags"]` index would not compile against the untagged entries), and is - // omitted entirely when no operation has a tag (it would be `never`). - const hasTags = ops.some((op) => op.tags.length > 0); - // `OperationId` is the union of descriptor `id` LITERALS — the spec operationIds the - // runtime exposes as `ctx.operation.id` — not the (possibly rename-sanitized) keys. - const derived = parseStatements( - 'export type OperationId = (typeof OPERATIONS)[keyof typeof OPERATIONS]["id"];\n' + - 'export type OperationPath = (typeof OPERATIONS)[keyof typeof OPERATIONS]["path"];' + - (hasTags - ? '\nexport type OperationTag = Extract<(typeof OPERATIONS)[keyof typeof OPERATIONS], { tags: readonly string[] }>["tags"][number];' - : '') - ); - return [operations, ...derived]; -} - -/** Text twin of `descriptorStatements` — printer-equivalent (pinned by its test). */ +/** `export const OPERATIONS = {…} as const satisfies …` + the derived unions. */ export function renderDescriptors( model: ApiModel, idents: Map, @@ -203,144 +131,3 @@ export function renderDescriptors( } return blocks.join('\n\n'); } - -/** - * `export type Ops = { : { args: …; result: …; kind?: "sse" } }` — the type map - * `createClient` consumes. A type alias (not an interface) on purpose: aliases get - * an implicit index signature, so `Ops` satisfies the runtime's `OpsShape` constraint; - * an interface would need an explicit `[key: string]` member. - */ -export function opsInterfaceStatements( - model: ApiModel, - idents: Map, - ctx: EmitContext -): ts.Statement[] { - const ops = allOperations(model.services); - if (ops.length === 0) return []; - return [ - jsdoc( - factory.createTypeAliasDeclaration( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - 'Ops', - undefined, - factory.createTypeLiteralNode(ops.map((op) => opsMember(op, idents.get(op.name)!, ctx))) - ), - "Per-operation `args`/`result` shapes (plus `kind: 'sse'` for event streams) — the\n" + - 'type-level companion of `OPERATIONS` that gives `createClient` its typed methods.' - ), - ]; -} - -/** One `: { args; result; kind? }` member of the `Ops` type. */ -function opsMember(op: OperationModel, ident: string, ctx: EmitContext): ts.PropertySignature { - const { pathParams } = operationSignature(op); - // Path params are keyed by WIRE name — the runtime routes `args[param.name]`. - const args = variablesTypeLiteral( - op, - pascalCase(op.name), - pathParams.map((p) => p.param), - new Map(pathParams.map((p) => [p.param.name, p.ident])), - ctx, - 'wire' - ); - const members = [ - factory.createPropertySignature(undefined, 'args', undefined, args), - factory.createPropertySignature(undefined, 'result', undefined, resultType(op, ctx)), - ]; - if (ctx.errorMode === 'result' && !isSseOp(op)) { - members.push( - factory.createPropertySignature( - undefined, - 'mode', - undefined, - factory.createLiteralTypeNode(factory.createStringLiteral('result')) - ) - ); - } - // Declared success-response headers type the throw-mode `{ envelope: true }` bag. - const responseHeaders = op.successResponseHeaders; - if (responseHeaders && responseHeaders.length > 0) { - members.push( - factory.createPropertySignature( - undefined, - 'headers', - undefined, - responseHeadersTypeLiteral(responseHeaders, ctx.schemas) - ) - ); - } - // Paginated operations declare the page's element type — it drives the runtime's - // `.pages()`/`.items()` members on the method (`Client` keys off `item`). - const paginated = ctx.pagination?.get(op.name); - if (paginated) { - members.push( - factory.createPropertySignature( - undefined, - 'item', - undefined, - schemaToTypeNode(paginated.itemSchema, ctx.dateType) - ) - ); - // Result mode wraps `result` in the envelope, but iteration unwraps it — `page` - // carries the RAW page type `.pages()` yields. Throw mode emits no `page` member - // (`Client`'s pages-generator falls back to `result`, already the raw page). - if (ctx.errorMode === 'result') { - members.push( - factory.createPropertySignature(undefined, 'page', undefined, rawResultRef(op, ctx)) - ); - } - } - if (isSseOp(op)) { - members.push( - factory.createPropertySignature( - undefined, - 'kind', - undefined, - factory.createLiteralTypeNode(factory.createStringLiteral('sse')) - ) - ); - } - return factory.createPropertySignature( - undefined, - ident, - undefined, - factory.createTypeLiteralNode(members) - ); -} - -/** - * The raw success-response reference — the same suppression rule as - * renderOperationParts: the emitted `Result` alias, or the inline response type - * when that name collides with a schema. - */ -function rawResultRef(op: OperationModel, ctx: EmitContext): ts.TypeNode { - const { responseType } = computeResponse(op.successResponses, ctx.dateType); - const resultName = `${pascalCase(op.name)}Result`; - return ctx.schemaNames.has(resultName) - ? responseType - : factory.createTypeReferenceNode(resultName); -} - -/** The `result` slot: SSE event payload, or the response type — `Result`-wrapped in result mode. */ -function resultType(op: OperationModel, ctx: EmitContext): ts.TypeNode { - if (isSseOp(op)) return sseEventType(op, ctx.dateType); - const resultRef = rawResultRef(op, ctx); - if (ctx.errorMode !== 'result') return resultRef; - return factory.createTypeReferenceNode('Result', [resultRef, errorTypeArg(op, ctx)]); -} - -/** - * The `Result<…, E>` error argument — the same composition `renderOperationParts` uses for - * `__requestResult`: `unknown` when the operation declares no error responses, the - * emitted `Error` alias otherwise, or the inline (union of) error type(s) when that - * alias name collides with a schema and is suppressed. - */ -function errorTypeArg(op: OperationModel, ctx: EmitContext): ts.TypeNode { - const errorMembers = errorTypeNodes(op.errorResponses, ctx.dateType); - if (errorMembers.length === 0) { - return factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword); - } - const errorAlias = `${pascalCase(op.name)}Error`; - if (!ctx.schemaNames.has(errorAlias)) return factory.createTypeReferenceNode(errorAlias); - return errorMembers.length === 1 ? errorMembers[0] : factory.createUnionTypeNode(errorMembers); -} diff --git a/packages/client-generator/src/emitters/faker.ts b/packages/client-generator/src/emitters/faker.ts index 590e906b66..0145663807 100644 --- a/packages/client-generator/src/emitters/faker.ts +++ b/packages/client-generator/src/emitters/faker.ts @@ -177,13 +177,16 @@ function boundsArg(meta: SchemaMetadata | undefined): string { return props.length > 0 ? `{ ${props.join(', ')} }` : ''; } -/** `faker.helpers.multiple(() => , { count: 1 })` — one element keeps output small. */ +/** `faker.helpers.multiple(() => , { count: 1 })` — one element keeps output small. + * An object-literal arrow body must be parenthesized (`() => ({ … })`), or the braces + * parse as a block. */ function multiple(item: MockValue): MockValue { + const object = isObjectValue(item); return { kind: 'wrap', - before: 'faker.helpers.multiple(() => ', + before: `faker.helpers.multiple(() => ${object ? '(' : ''}`, value: item, - after: ', { count: 1 })', + after: `${object ? ')' : ''}, { count: 1 })`, }; } diff --git a/packages/client-generator/src/emitters/operation-aliases.ts b/packages/client-generator/src/emitters/operation-aliases.ts deleted file mode 100644 index 63bb34dd9f..0000000000 --- a/packages/client-generator/src/emitters/operation-aliases.ts +++ /dev/null @@ -1,277 +0,0 @@ -// The `*` derived type-alias builders (`Result`/`Error`/`Params`/`Body`/`Headers`/`Variables`). -// Split out of operations.ts: this is the cohesive cluster the sdk emits so callers can name -// intermediate values. Reuses the shared type builders (operation-types.ts) and the block-wide -// `EmitContext` (a type-only import from operations.ts — erased, so there is no runtime cycle). - -import type { OperationModel, ParamModel } from '../intermediate-representation/model.js'; -import { safeIdent } from './identifier.js'; -import { jsdocText } from './jsdoc.js'; -import { operationSignature } from './operation-signature.js'; -import { bodyTypeNode, paramsTypeLiteral, propertyKey } from './operation-types.js'; -import type { EmitContext } from './operations.js'; -import { responseHeadersTypeLiteral } from './response-headers.js'; -import { pascalCase } from './support.js'; -import { jsdoc, ts } from './ts.js'; -import { schemaToTypeNode } from './types.js'; - -const { factory } = ts; - -/** - * Emit derived type aliases for an operation so callers can name intermediate - * values without re-deriving via `Awaited>` plumbing. - * - * `*Result` is always emitted (even for `void`). The others are conditional on - * the operation actually having the corresponding inputs — emitting empty - * `*Params = {}` or `*Body = unknown` aliases would just be noise. - */ -export function renderOperationAliases( - op: OperationModel, - responseType: ts.TypeNode, - orderedPathParams: ParamModel[], - pathParamIdent: Map, - errorAlias: string, - errorMembers: ts.TypeNode[], - ctx: EmitContext, - // SSE ops have no one-shot response, so they omit `*Result`/`*Error` and keep only the input - // aliases. - emitResultAndError = true, - // Threaded to the `Variables` body — see `variablesTypeLiteral`. - pathKeys: 'ident' | 'wire' = 'ident' -): ts.Statement[] { - const { dateType, schemaNames } = ctx; - const name = pascalCase(op.name); - const aliases: ts.Statement[] = []; - - // Every derived alias is suppressed when its name collides with an exported schema (a - // duplicate `export type` is a TS2300 error). References to a suppressed alias inline the - // underlying type instead — see `renderOperationParts` (Result/Error) and `renderVariablesAlias` - // / the grouped signature (Params/Body/Headers/Variables). - - // Emit `export type Result = …` unless its name collides with an exported schema. Two - // cases collide: self-referential (operation `search` returning schema `SearchResult` → - // `export type SearchResult = SearchResult;`, circular) and plain (operation `login` returning - // some other type while a `LoginResult` schema also exists). In both, call sites reference the - // response type directly (`renderOperationParts`), so the alias is redundant. - const resultName = `${name}Result`; - if (emitResultAndError && !schemaNames.has(resultName)) { - aliases.push(exportType(resultName, responseType)); - } - - // Result mode only, and only when the operation declares error responses: the typed `error`. - if (emitResultAndError && errorAlias && !schemaNames.has(errorAlias)) { - aliases.push( - exportType( - errorAlias, - errorMembers.length === 1 ? errorMembers[0] : factory.createUnionTypeNode(errorMembers) - ) - ); - } - - if (op.queryParams.length > 0 && !schemaNames.has(`${name}Params`)) { - // Reuse the params type-literal builder so the alias body picks up per-prop - // JSDoc automatically — no second renderer to keep in sync. - aliases.push(exportType(`${name}Params`, paramsTypeLiteral(op.queryParams, dateType))); - } - - if (op.requestBody && !schemaNames.has(`${name}Body`)) { - // Use the same content-type → TS-type mapping as the function signature, so - // `Body` matches the second-positional arg of the function exactly. - aliases.push(exportType(`${name}Body`, bodyTypeNode(op.requestBody, dateType))); - } - - if (op.headerParams.length > 0 && !schemaNames.has(`${name}Headers`)) { - aliases.push(exportType(`${name}Headers`, paramsTypeLiteral(op.headerParams, dateType))); - } - - // Response headers (envelope) — distinct from request `Headers`. - const responseHeaders = op.successResponseHeaders; - if (responseHeaders && responseHeaders.length > 0 && !schemaNames.has(`${name}ResponseHeaders`)) { - aliases.push( - exportType(`${name}ResponseHeaders`, responseHeadersTypeLiteral(responseHeaders, ctx.schemas)) - ); - } - - if (op.cookieParams.length > 0 && !schemaNames.has(`${name}Cookies`)) { - aliases.push(exportType(`${name}Cookies`, paramsTypeLiteral(op.cookieParams, dateType))); - } - - if (!schemaNames.has(`${name}Variables`)) { - const variables = renderVariablesAlias( - op, - name, - orderedPathParams, - pathParamIdent, - ctx, - pathKeys - ); - if (variables) aliases.push(variables); - } - - return aliases; -} - -/** - * An SSE op's input aliases (`*Params` / `*Body` / `*Headers` / `*Variables`) — but NOT - * `*Result`/`*Error`, which describe a one-shot response an event stream has no equivalent of. - * Passes the real `schemaNames` so the input aliases still get collision suppression, and sets - * `emitResultAndError = false` to omit the result/error pair. - */ -export function sseAliases( - op: OperationModel, - orderedPathParams: ParamModel[], - pathParamIdent: Map, - ctx: EmitContext, - pathKeys: 'ident' | 'wire' = 'ident' -): ts.Statement[] { - return renderOperationAliases( - op, - factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword), - orderedPathParams, - pathParamIdent, - '', - [], - ctx, - false, - pathKeys - ); -} - -/** `export type = ;` */ -function exportType(name: string, type: ts.TypeNode): ts.TypeAliasDeclaration { - return factory.createTypeAliasDeclaration( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - name, - undefined, - type - ); -} - -/** - * Combined inputs alias — a single object that bundles every positional input - * the operation function accepts. The seam React Query / SWR wrappers use: - * - * useMutation({ mutationFn: (vars: UpdateOrderVariables) => updateOrder(vars.orderId, vars.params, vars.body) }) - * - * Conventions: - * - Property order: path params (URL-template order), then `params`, then `body`, - * then `headers`. Mirrors the function's positional argument order. - * - Path-param props carry the same JSDoc (description + schema metadata) the - * function declaration omits. - * - `params?` is optional iff the function signature defaults to `= {}` (all query - * params optional). Same rule for `body?` / `headers?`. - * - References `Params` / `Body` / `Headers` when those aliases were - * also emitted, so the aliases stay in sync without duplicating their bodies. - * - * Returns `undefined` for operations with no inputs at all. - */ -function renderVariablesAlias( - op: OperationModel, - name: string, - orderedPathParams: ParamModel[], - pathParamIdent: Map, - ctx: EmitContext, - pathKeys: 'ident' | 'wire' -): ts.TypeAliasDeclaration | undefined { - if (!operationSignature(op).hasInputs) return undefined; - return exportType( - name + 'Variables', - variablesTypeLiteral(op, name, orderedPathParams, pathParamIdent, ctx, pathKeys) - ); -} - -/** - * The `Variables` object type literal (the body of the alias, reused inline by the grouped - * signature when the alias name itself collides). Each `params`/`body`/`headers` property - * references its `X` alias, or inlines the type when that alias name collides with a schema - * (so a suppressed alias is never referenced). - */ -export function variablesTypeLiteral( - op: OperationModel, - name: string, - orderedPathParams: ParamModel[], - pathParamIdent: Map, - ctx: EmitContext, - // How path-param properties are keyed: `'ident'` (default) uses the sanitized identifier - // that doubles as the flat positional argument; `'wire'` (package mode) uses the spec's - // param name — quoted when needed — because the runtime routes `args[param.name]`. - pathKeys: 'ident' | 'wire' = 'ident' -): ts.TypeNode { - const { dateType, schemaNames } = ctx; - const props: ts.PropertySignature[] = []; - - for (const p of orderedPathParams) { - // Ident mode: same safe identifier the function uses, so a wrapper can map - // `vars.` straight onto the positional argument. - const sig = factory.createPropertySignature( - undefined, - pathKeys === 'wire' ? propertyKey(safeIdent(p.name)) : pathParamIdent.get(p.name)!, - undefined, - schemaToTypeNode(p.schema, dateType) - ); - const doc = jsdocText(p.description, p.schema.metadata); - props.push(doc === undefined ? sig : jsdoc(sig, doc)); - } - - if (op.queryParams.length > 0) { - props.push( - inputProp( - 'params', - `${name}Params`, - () => paramsTypeLiteral(op.queryParams, dateType), - op.queryParams.some((p) => p.required), - schemaNames - ) - ); - } - if (op.requestBody) { - props.push( - inputProp( - 'body', - `${name}Body`, - () => bodyTypeNode(op.requestBody!, dateType), - op.requestBody.required, - schemaNames - ) - ); - } - if (op.headerParams.length > 0) { - props.push( - inputProp( - 'headers', - `${name}Headers`, - () => paramsTypeLiteral(op.headerParams, dateType), - op.headerParams.some((p) => p.required), - schemaNames - ) - ); - } - if (op.cookieParams.length > 0) { - props.push( - inputProp( - 'cookies', - `${name}Cookies`, - () => paramsTypeLiteral(op.cookieParams, dateType), - op.cookieParams.some((p) => p.required), - schemaNames - ) - ); - } - - return factory.createTypeLiteralNode(props); -} - -/** A `(?): ` property, or an inline-typed one when `` collides with a schema. */ -function inputProp( - key: string, - alias: string, - inlineType: () => ts.TypeNode, - required: boolean, - schemaNames: Set -): ts.PropertySignature { - return factory.createPropertySignature( - undefined, - key, - required ? undefined : factory.createToken(ts.SyntaxKind.QuestionToken), - schemaNames.has(alias) ? inlineType() : factory.createTypeReferenceNode(alias) - ); -} diff --git a/packages/client-generator/src/emitters/operation-types.ts b/packages/client-generator/src/emitters/operation-types.ts index 9b43a60e8b..951cd00d54 100644 --- a/packages/client-generator/src/emitters/operation-types.ts +++ b/packages/client-generator/src/emitters/operation-types.ts @@ -1,71 +1,7 @@ -// TypeScript type/parameter builders shared by the operation emitter and the operation-alias -// builders: turn an operation's params / body / responses into `ts` type and parameter nodes. -// Leaf module — depends only on the IR types and the emit foundation, never back on operations.ts. +// Shared operation-shape predicates. The type/parameter RENDERING that used to +// live here moved to the text templates in render-client.ts. -import type { - ParamModel, - RequestBodyModel, - ResponseBodyModel, -} from '../intermediate-representation/model.js'; -import { safeIdent } from './identifier.js'; -import { jsdocText } from './jsdoc.js'; -import { jsdoc, printNodes, ts } from './ts.js'; -import { type DateType, schemaToTypeNode } from './types.js'; - -const { factory } = ts; - -/** A `: ` parameter, defaulting to `= {}` when `withDefault`. */ -export function simpleParam( - name: string, - type: ts.TypeNode, - withDefault: boolean -): ts.ParameterDeclaration { - return factory.createParameterDeclaration( - undefined, - undefined, - name, - undefined, - type, - withDefault ? factory.createObjectLiteralExpression([], false) : undefined - ); -} - -/** - * A `: { … }` argument bundling `params` into one object, each property - * carrying its own JSDoc (description + metadata). Defaults to `= {}` when every - * property is optional. Shared by the query `params` and the operation `headers` - * slots, which have the identical layout. - */ -export function renderParamsObjectArg( - slot: string, - params: ParamModel[], - dateType: DateType -): ts.ParameterDeclaration { - return simpleParam(slot, paramsTypeLiteral(params, dateType), !params.some((p) => p.required)); -} - -/** The `{ … }` type literal for a params object (query or headers), with per-prop JSDoc. */ -export function paramsTypeLiteral(params: ParamModel[], dateType: DateType): ts.TypeLiteralNode { - return factory.createTypeLiteralNode( - params.map((p) => { - const sig = factory.createPropertySignature( - undefined, - propertyKey(safeIdent(p.name)), - p.required ? undefined : factory.createToken(ts.SyntaxKind.QuestionToken), - schemaToTypeNode(p.schema, dateType) - ); - const doc = jsdocText(p.description, p.schema.metadata); - return doc === undefined ? sig : jsdoc(sig, doc); - }) - ); -} - -/** A bare identifier key when valid, a quoted string-literal key otherwise. */ -export function propertyKey(safe: string): ts.PropertyName { - return safe.startsWith('"') - ? factory.createStringLiteral(JSON.parse(safe) as string) - : factory.createIdentifier(safe); -} +import type { RequestBodyModel } from '../intermediate-representation/model.js'; /** * A multipart body whose schema is a concrete object — the case worth typing. Such a body @@ -76,80 +12,3 @@ export function propertyKey(safe: string): ts.PropertyName { export function isTypedMultipart(rb: RequestBodyModel): boolean { return rb.contentType === 'multipart/form-data' && rb.schema.kind === 'object'; } - -/** The request-body TS type: special wrapper types per content-type, else the schema. */ -export function bodyTypeNode(rb: RequestBodyModel, dateType: DateType): ts.TypeNode { - if (isTypedMultipart(rb)) return schemaToTypeNode(rb.schema, dateType); - switch (rb.contentType) { - case 'multipart/form-data': - return factory.createTypeReferenceNode('FormData'); - case 'application/x-www-form-urlencoded': - return factory.createTypeReferenceNode('URLSearchParams'); - case 'application/octet-stream': - return factory.createUnionTypeNode([ - factory.createTypeReferenceNode('Blob'), - factory.createTypeReferenceNode('ArrayBuffer'), - ]); - default: - return schemaToTypeNode(rb.schema, dateType); - } -} - -/** The deduped error-response body type nodes (by printed form), or `[]` when none. */ -export function errorTypeNodes(responses: ResponseBodyModel[], dateType: DateType): ts.TypeNode[] { - const seen = new Set(); - const nodes: ts.TypeNode[] = []; - for (const r of responses) { - const node = schemaToTypeNode(r.schema, dateType); - const key = printNodes([node]); - if (seen.has(key)) continue; - seen.add(key); - nodes.push(node); - } - return nodes; -} - -export function computeResponse( - responses: ResponseBodyModel[], - dateType: DateType -): { - responseType: ts.TypeNode; - responseKind: 'json' | 'blob' | 'text' | 'void'; -} { - if (responses.length === 0) - return { - responseType: factory.createKeywordTypeNode(ts.SyntaxKind.VoidKeyword), - responseKind: 'void', - }; - - // Prefer JSON; fall back to other content types. - const jsonResponse = responses.find((r) => r.contentType.toLowerCase().includes('json')); - if (jsonResponse) { - return { responseType: schemaToTypeNode(jsonResponse.schema, dateType), responseKind: 'json' }; - } - // No JSON — handle binary/text gracefully. - const nodes: ts.TypeNode[] = []; - const seen = new Set(); - let hasBinary = false; - let hasText = false; - for (const r of responses) { - let node: ts.TypeNode; - if (r.contentType.startsWith('image/') || r.contentType === 'application/octet-stream') { - node = factory.createTypeReferenceNode('Blob'); - hasBinary = true; - } else if (r.contentType.startsWith('text/')) { - node = factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword); - hasText = true; - } else { - node = schemaToTypeNode(r.schema, dateType); - } - const key = printNodes([node]); - if (seen.has(key)) continue; - seen.add(key); - nodes.push(node); - } - // `nodes` is guaranteed non-empty here: each iteration above always builds one. - const responseType = nodes.length === 1 ? nodes[0] : factory.createUnionTypeNode(nodes); - const responseKind: 'blob' | 'text' | 'json' = hasBinary ? 'blob' : hasText ? 'text' : 'json'; - return { responseType, responseKind }; -} diff --git a/packages/client-generator/src/emitters/operations.ts b/packages/client-generator/src/emitters/operations.ts index 258c0f1fc1..1708ba4552 100644 --- a/packages/client-generator/src/emitters/operations.ts +++ b/packages/client-generator/src/emitters/operations.ts @@ -1,15 +1,5 @@ -import type { - NamedSchemaModel, - OperationModel, - ParamModel, -} from '../intermediate-representation/model.js'; -import { bodyTypeNode, renderParamsObjectArg, simpleParam } from './operation-types.js'; import type { ModelPagination } from './pagination.js'; -import { isSseOp } from './sse.js'; -import { ts } from './ts.js'; -import { type DateType, schemaToTypeNode } from './types.js'; - -const { factory } = ts; +import type { DateType } from './types.js'; /** Error-handling shape of the generated client: throw on non-2xx, or return a result union. */ export type ErrorMode = 'throw' | 'result'; @@ -37,59 +27,6 @@ export type EmitContext = { 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; }; - -/** - * The flat sugar's parameter list: path params spread as positional args (in URL - * template order), then the `params`/`body`/`headers`/`cookies` slots, ending with - * the trailing `init: RequestOptions` (`SseOptions` for streams). Optional slots - * default to `= {}` so trailing arguments can be omitted. - */ -export function renderArgList( - op: OperationModel, - orderedPathParams: ParamModel[], - pathParamIdent: Map, - ctx: EmitContext -): ts.ParameterDeclaration[] { - const { dateType } = ctx; - const args: ts.ParameterDeclaration[] = []; - for (const p of orderedPathParams) { - args.push( - simpleParam(pathParamIdent.get(p.name)!, schemaToTypeNode(p.schema, dateType), false) - ); - } - if (op.queryParams.length > 0) - args.push(renderParamsObjectArg('params', op.queryParams, dateType)); - if (op.requestBody) { - const type = bodyTypeNode(op.requestBody, dateType); - args.push( - factory.createParameterDeclaration( - undefined, - undefined, - 'body', - op.requestBody.required ? undefined : factory.createToken(ts.SyntaxKind.QuestionToken), - type - ) - ); - } - // Operation header params are explicit, typed inputs; security-scheme headers - // are injected by the runtime and live underneath them. - if (op.headerParams.length > 0) - args.push(renderParamsObjectArg('headers', op.headerParams, dateType)); - if (op.cookieParams.length > 0) - args.push(renderParamsObjectArg('cookies', op.cookieParams, dateType)); - // SSE ops take per-stream `SseOptions` (reconnect knobs); everyone else the - // standard per-call `RequestOptions`. - args.push( - simpleParam( - 'init', - factory.createTypeReferenceNode(isSseOp(op) ? 'SseOptions' : 'RequestOptions'), - true - ) - ); - return args; -} diff --git a/packages/client-generator/src/emitters/sse.ts b/packages/client-generator/src/emitters/sse.ts index 2169fb903a..a91d1bbc1e 100644 --- a/packages/client-generator/src/emitters/sse.ts +++ b/packages/client-generator/src/emitters/sse.ts @@ -3,10 +3,6 @@ import type { ResponseBodyModel, SchemaModel, } from '../intermediate-representation/model.js'; -import { ts } from './ts.js'; -import { type DateType, schemaToTypeNode } from './types.js'; - -const { factory } = ts; /** The media type that marks an operation as a Server-Sent Events stream. */ const SSE_CONTENT_TYPE = 'text/event-stream'; @@ -32,14 +28,6 @@ export function eventSchema(op: OperationModel): SchemaModel | undefined { return undefined; } -/** The TS type of a streamed event payload (`string` when no schema is declared). */ -export function sseEventType(op: OperationModel, dateType: DateType): ts.TypeNode { - const schema = eventSchema(op); - return schema - ? schemaToTypeNode(schema, dateType) - : factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword); -} - /** Whether the streamed `data:` payload should be `JSON.parse`d (`'json'`) or passed raw (`'text'`). */ export function sseDataKind(op: OperationModel): 'json' | 'text' { const schema = eventSchema(op); diff --git a/packages/client-generator/src/emitters/type-guards.ts b/packages/client-generator/src/emitters/type-guards.ts index 1394f8c67d..0690208256 100644 --- a/packages/client-generator/src/emitters/type-guards.ts +++ b/packages/client-generator/src/emitters/type-guards.ts @@ -3,7 +3,6 @@ import type { NamedSchemaModel, SchemaModel, } from '../intermediate-representation/model.js'; -import { jsdoc, ts } from './ts.js'; /** * A discriminated union we can emit guards for, found while walking the schema @@ -16,69 +15,8 @@ import { jsdoc, ts } from './ts.js'; type UnionSite = { union: Extract; label: string; - makeParamType: () => ts.TypeNode; }; -/** - * Emit `is(value): value is ` type guards for every discriminated - * union with a usable discriminator — whether it is a top-level named schema - * (`MenuItem = A | B`) or nested inside one (e.g. the `items` of an array, the - * value of a property). Two discriminator sources: - * - * - Explicit: the union carries a `discriminator` (built from the spec). - * - Implicit: no discriminator, but every member is a ref to a named schema and - * they all constrain one shared property to a distinct string `const`. - * - * Nested unions only qualify when every member is a ref to a named schema, so the - * `value` parameter is a clean union of exported types. Guard names are globally - * deduped (`is`), keeping the first in document order — so a top-level - * union wins its nicer `value: ` parameter over a nested re-occurrence. - * Undiscriminated unions are skipped — TypeScript can't soundly narrow them. - * Returns the guard declarations as nodes (empty when no union narrows). - */ -export function typeGuardStatements(schemas: NamedSchemaModel[]): ts.FunctionDeclaration[] { - const byName = new Map(schemas.map((s) => [s.name, s.schema] as const)); - const nodes: ts.FunctionDeclaration[] = []; - const emitted = new Set(); - - for (const named of schemas) { - for (const site of collectUnionSites(named)) { - const discriminator = - site.union.discriminator ?? detectImplicitDiscriminator(site.union, byName); - if (!discriminator) continue; - - // Group discriminant values by target schema so two mapping keys pointing at - // the same type produce one guard (a duplicate `is` would not compile). - const valuesByTarget = new Map(); - for (const entry of discriminator.mapping) { - if (!byName.has(entry.schemaName)) continue; - const existing = valuesByTarget.get(entry.schemaName); - if (existing) existing.push(entry.value); - else valuesByTarget.set(entry.schemaName, [entry.value]); - } - - for (const [schemaName, values] of valuesByTarget) { - const guardName = `is${schemaName}`; - if (emitted.has(guardName)) continue; - emitted.add(guardName); - nodes.push( - buildTypeGuard( - site.makeParamType(), - site.label, - discriminator.propertyName, - schemaName, - values - ) - ); - } - } - } - - return nodes; -} - -const { factory } = ts; - /** Text twin of `typeGuardStatements` (printer-equivalence-pinned); same detection, string body. */ export function renderTypeGuards(schemas: NamedSchemaModel[]): string { const byName = new Map(schemas.map((s) => [s.name, s.schema] as const)); @@ -131,11 +69,7 @@ function collectUnionSites(named: NamedSchemaModel): UnionSite[] { const sites: UnionSite[] = []; const root = named.schema; if (root.kind === 'union') { - sites.push({ - union: root, - label: named.name, - makeParamType: () => factory.createTypeReferenceNode(named.name), - }); + sites.push({ union: root, label: named.name }); for (const member of root.members) collectNestedSites(member, sites); } else { collectNestedSites(root, sites); @@ -149,12 +83,7 @@ function collectNestedSites(schema: SchemaModel, sites: UnionSite[]): void { case 'union': { const names = schema.members.map((m) => (m.kind === 'ref' ? m.name : undefined)); if (names.every((n): n is string => n !== undefined)) { - sites.push({ - union: schema, - label: names.join(' | '), - makeParamType: () => - factory.createUnionTypeNode(names.map((n) => factory.createTypeReferenceNode(n))), - }); + sites.push({ union: schema, label: names.join(' | ') }); } for (const member of schema.members) collectNestedSites(member, sites); break; @@ -175,75 +104,6 @@ function collectNestedSites(schema: SchemaModel, sites: UnionSite[]): void { } } -/** `(value as Record)[]` — the narrowed property access. */ -function propertyAccess(propertyName: string): ts.Expression { - const recordType = factory.createTypeReferenceNode('Record', [ - factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword), - ]); - return factory.createElementAccessExpression( - factory.createAsExpression(factory.createIdentifier('value'), recordType), - factory.createStringLiteral(propertyName) - ); -} - -function buildTypeGuard( - paramType: ts.TypeNode, - unionLabel: string, - propertyName: string, - schemaName: string, - values: string[] -): ts.FunctionDeclaration { - const access = propertyAccess(propertyName); - const check = - values.length === 1 - ? factory.createBinaryExpression( - access, - factory.createToken(ts.SyntaxKind.EqualsEqualsEqualsToken), - factory.createStringLiteral(values[0]) - ) - : // `([...values] as readonly unknown[]).includes()` - factory.createCallExpression( - factory.createPropertyAccessExpression( - factory.createParenthesizedExpression( - factory.createAsExpression( - factory.createArrayLiteralExpression( - values.map((v) => factory.createStringLiteral(v)) - ), - factory.createTypeOperatorNode( - ts.SyntaxKind.ReadonlyKeyword, - factory.createArrayTypeNode( - factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword) - ) - ) - ) - ), - 'includes' - ), - undefined, - [access] - ); - - const fn = factory.createFunctionDeclaration( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - undefined, - `is${schemaName}`, - undefined, - [factory.createParameterDeclaration(undefined, undefined, 'value', undefined, paramType)], - factory.createTypePredicateNode( - undefined, - 'value', - factory.createTypeReferenceNode(schemaName) - ), - factory.createBlock([factory.createReturnStatement(check)], true) - ); - - return jsdoc( - fn, - `Narrow a \`${unionLabel}\` to \`${schemaName}\` via its \`${propertyName}\` discriminant.` - ); -} - /** * Detect an implicit discriminator: every member is a ref to a named schema, * and they all pin one shared property to a distinct string literal. Returns diff --git a/packages/client-generator/src/emitters/types.ts b/packages/client-generator/src/emitters/types.ts index 43ea994246..4dad85754a 100644 --- a/packages/client-generator/src/emitters/types.ts +++ b/packages/client-generator/src/emitters/types.ts @@ -1,16 +1,3 @@ -import type { - NamedSchemaModel, - PropertyModel, - ScalarKind, - SchemaMetadata, - SchemaModel, -} from '../intermediate-representation/model.js'; -import { isIdentifier, safeIdent } from './identifier.js'; -import { jsdocText } from './jsdoc.js'; -import { jsdoc, literalExpression, printNodes, ts } from './ts.js'; - -const { factory } = ts; - /** * How `format: date-time`/`date` string fields are typed: * - `'string'` (default): the wire shape — an ISO string. @@ -18,180 +5,3 @@ const { factory } = ts; * so the runtime value matches (the client stays zero-dep — `Date` is standard). */ export type DateType = 'string' | 'Date'; - -/** The model type aliases (and const-object enum companions) as nodes. */ -export function typesStatements( - schemas: NamedSchemaModel[], - dateType: DateType = 'string' -): ts.Statement[] { - const nodes: ts.Statement[] = []; - for (const s of schemas) { - nodes.push( - jsdocOn( - factory.createTypeAliasDeclaration( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - s.name, - undefined, - schemaToTypeNode(s.schema, dateType) - ), - s.schema.description ?? s.description, - s.schema.metadata - ) - ); - const constObject = enumConstObject(s); - if (constObject) nodes.push(constObject); - } - return nodes; -} - -/** - * For a named **string** enum, build a runtime companion - * `export const X = { a: "a", … } as const;` that cohabits with the same-named - * type (TypeScript allows a type and value to share an identifier). This lets - * callers reference values at runtime (`X.a`) instead of retyping literals. - * - * Returns `undefined` (so only the type union is emitted) when: - * - the schema isn't a string enum (integer/boolean enums gain nothing), or - * - any value isn't a valid JS identifier (e.g. `"menu:read"`) — we don't emit - * a half-usable object with quoted keys. - */ -function enumConstObject(named: NamedSchemaModel): ts.VariableStatement | undefined { - const schema = named.schema; - if (schema.kind !== 'enum' || schema.scalar !== 'string') return undefined; - if (!schema.values.every((v) => typeof v === 'string' && isIdentifier(v))) return undefined; - - const object = factory.createObjectLiteralExpression( - schema.values.map((v) => - factory.createPropertyAssignment(v as string, factory.createStringLiteral(v as string)) - ), - true - ); - return factory.createVariableStatement( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - factory.createVariableDeclarationList( - [ - factory.createVariableDeclaration( - named.name, - undefined, - undefined, - factory.createAsExpression(object, factory.createTypeReferenceNode('const')) - ), - ], - ts.NodeFlags.Const - ) - ); -} - -export function renderSchema(schema: SchemaModel, dateType: DateType = 'string'): string { - return printNodes([schemaToTypeNode(schema, dateType)]); -} - -/** Build the TypeScript type node for an IR schema. */ -export function schemaToTypeNode(schema: SchemaModel, dateType: DateType = 'string'): ts.TypeNode { - switch (schema.kind) { - case 'scalar': - return scalarTypeNode(schema.scalar, schema.metadata, dateType); - case 'ref': - return factory.createTypeReferenceNode(schema.name); - case 'literal': - return factory.createLiteralTypeNode(literalExpression(schema.value)); - case 'enum': { - const members = schema.values.map((v) => factory.createLiteralTypeNode(literalExpression(v))); - // A single-value enum is just that literal — wrapping it in a one-member - // union would make the printer parenthesize it inside `T[]` (`("a")[]`). - return members.length === 1 ? members[0] : factory.createUnionTypeNode(members); - } - case 'null': - return factory.createLiteralTypeNode(factory.createNull()); - case 'unknown': - return factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword); - case 'array': - // The printer parenthesizes union/intersection element types itself - // (`(string | null)[]`), so just hand it the element node. - return factory.createArrayTypeNode(schemaToTypeNode(schema.items, dateType)); - case 'record': - return factory.createTypeReferenceNode('Record', [ - factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), - schemaToTypeNode(schema.value, dateType), - ]); - case 'object': - return factory.createTypeLiteralNode( - schema.properties.map((p) => propertySignature(p, dateType)) - ); - case 'union': - return factory.createUnionTypeNode(schema.members.map((m) => schemaToTypeNode(m, dateType))); - case 'intersection': - return factory.createIntersectionTypeNode( - schema.members.map((m) => schemaToTypeNode(m, dateType)) - ); - case 'omit': - return factory.createTypeReferenceNode('Omit', [ - factory.createTypeReferenceNode(schema.base), - factory.createUnionTypeNode( - schema.keys.map((k) => factory.createLiteralTypeNode(factory.createStringLiteral(k))) - ), - ]); - } -} - -function propertySignature(p: PropertyModel, dateType: DateType): ts.PropertySignature { - // `readOnly` (server-managed) props get the `readonly` modifier so consumer - // write-type utilities (OmitReadOnly) can strip them and assignment is - // flagged. Request-body types already drop these via `Omit` in the IR. - const modifiers = p.readOnly - ? [factory.createModifier(ts.SyntaxKind.ReadonlyKeyword)] - : undefined; - const sig = factory.createPropertySignature( - modifiers, - propertyName(p.name), - p.required ? undefined : factory.createToken(ts.SyntaxKind.QuestionToken), - schemaToTypeNode(p.schema, dateType) - ); - return jsdocOn(sig, p.description, p.schema.metadata); -} - -/** A property name: a bare identifier when valid, a quoted string literal otherwise. */ -function propertyName(name: string): ts.PropertyName { - const safe = safeIdent(name); - return safe === name ? factory.createIdentifier(name) : factory.createStringLiteral(name); -} - -function scalarTypeNode( - kind: ScalarKind, - metadata: SchemaMetadata | undefined, - dateType: DateType -): ts.TypeNode { - switch (kind) { - case 'string': - // `format: binary` is raw byte content (file uploads / octet-stream), not text — - // surface it as `Blob` (the web standard; a `File` is assignable to it). `byte` - // (base64) stays a `string`. - if (metadata?.format === 'binary') { - return factory.createTypeReferenceNode('Blob'); - } - // Opt-in: a `date-time`/`date` string surfaces as `Date` under `dateType: - // 'Date'`; everything else (and the default) stays the `string` keyword. - if ( - dateType === 'Date' && - (metadata?.format === 'date-time' || metadata?.format === 'date') - ) { - return factory.createTypeReferenceNode('Date'); - } - return factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword); - case 'number': - case 'integer': - return factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword); - case 'boolean': - return factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword); - } -} - -/** Attach a JSDoc block (description + metadata tags) to `node`, if any. */ -function jsdocOn( - node: T, - text: string | undefined, - metadata?: SchemaMetadata -): T { - const body = jsdocText(text, metadata); - return body === undefined ? node : jsdoc(node, body); -} diff --git a/packages/client-generator/src/generate.ts b/packages/client-generator/src/generate.ts index 8cca403333..27306cb657 100644 --- a/packages/client-generator/src/generate.ts +++ b/packages/client-generator/src/generate.ts @@ -1,8 +1,9 @@ // The generate entry (`@redocly/client-generator/generate`): the TypeScript-emitting -// toolkit for custom generators plus `collectGeneratedFiles` and a `generateClient` -// re-export. It loads `typescript` and `@redocly/openapi-core`, so it must never be -// reached statically from the package root: package-mode clients import the root at -// app runtime, and the root reaches the pipeline only through a dynamic import. +// text toolkit for custom generators plus `collectGeneratedFiles` and a `generateClient` +// re-export. It loads `@redocly/openapi-core` (and, only for `--setup` baking, +// `typescript` — lazily), so it must never be reached statically from the package +// root: package-mode clients import the root at app runtime, and the root reaches +// the pipeline only through a dynamic import. import type { EmitOptions } from './emitters/emit-options.js'; import { builtinGenerators, validateGenerators } from './generators/index.js'; @@ -11,22 +12,17 @@ import type { ApiModel } from './intermediate-representation/model.js'; import { runGenerators } from './pipeline.js'; // --- Codegen toolkit: build TypeScript the same way the built-in generators do ----------------- -export { - arrow, - constArray, - exportConstStatement, - jsdoc, - parseStatements, - printNodes, - printStatements, - ts, -} from './emitters/ts.js'; +// Source-text templates, not an AST: the `ts.factory`/printer exports were removed +// when every built-in generator migrated to text (one authoring model for every +// output language). `tsType`/`tsJsdoc`/`codeLiteral` are the TypeScript-specific +// text renderers the sdk itself uses. +export { tsJsdoc, tsType } from './emitters/ts-type.js'; +export { codeLiteral } from './emitters/ts-literal.js'; // The language-neutral authoring helpers, re-exported here so both toolkit // entries offer the full authoring surface (the root offers them TS-free). export * from './authoring/index.js'; export { operationSignature } from './emitters/operation-signature.js'; export type { OperationSignature } from './emitters/operation-signature.js'; -export { schemaToTypeNode } from './emitters/types.js'; export { pascalCase } from './emitters/support.js'; export { safeIdent } from './emitters/identifier.js'; diff --git a/tests/e2e/generate-client/examples/README.md b/tests/e2e/generate-client/examples/README.md index 781006a298..ce985b94fc 100644 --- a/tests/e2e/generate-client/examples/README.md +++ b/tests/e2e/generate-client/examples/README.md @@ -6,32 +6,32 @@ Most share the cafe spec in [`_shared/cafe.yaml`](./_shared/cafe.yaml); the rest The generated client under `src/api/` is gitignored — CI regenerates every client and type-checks the consumer code against it (the `examples` job), and `zero-install-quickstart` keeps its client committed as the canonical browsable copy, drift-checked in `tests/e2e/generate-client/examples.test.ts`. -| Example | How it's generated | Shows | -| ------------------------------------------------------ | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| [fetch-functions](./fetch-functions) | CLI · `sdk`, functions | free functions + `ApiError` | -| [baked-setup](./baked-setup) | CLI · `sdk`, functions | publisher defaults baked into the client via `--setup` (`defineClientSetup`) | -| [zod](./zod) | CLI · `sdk`, `zod` | validating responses with generated zod schemas | -| [tanstack-query](./tanstack-query) | CLI · `sdk`, `tanstack-query` | React `useQuery(Options())` | -| [mock](./mock) | CLI · `sdk`, `mock` | MSW handlers from generated `handlers` | -| [programmatic](./programmatic) | `generateClient(...)` API | generating the client from a Node script | -| [package-runtime](./package-runtime) | CLI · `sdk`, package runtime | `runtime: package` — types + descriptors only; the versioned runtime is imported from `@redocly/client-generator`, fixes via `npm update` | -| [zero-install-quickstart](./zero-install-quickstart) | CLI · `sdk` | the first-touch loop: generate → import → call; one self-contained file, zero runtime dependencies | -| [node-native](./node-native) | CLI · `sdk` | `importExt: ts` — `.ts` import specifiers so plain `node src/main.ts` runs the client via Node's built-in type stripping | -| [configure-and-middleware](./configure-and-middleware) | CLI · `sdk` | `configure({ serverUrl, retry, fetch })`, `use()` targeting `ctx.operation` (literal unions), body mutation, auth setter, `ApiError.body` | -| [multi-instance](./multi-instance) | CLI · `sdk`, package runtime | per-tenant instances via `createClient(OPERATIONS)` — works in both runtimes; this example uses `runtime: package` | -| [sse-streaming](./sse-streaming) | CLI · `sdk` | typed `for await` over SSE, auto-reconnect via `Last-Event-ID` (`reconnectDelay`/`reconnect: false`), clean abort | -| [vendored-edge](./vendored-edge) | CLI · `sdk` | the generated file copied into a no-npm edge worker (`export default { fetch }`); `typescript` is the only dev tool | -| [pagination](./pagination) | CLI · `sdk` | auto-pagination from a `client.pagination` convention: `for await` over `.items()`/`.pages()` next to the unchanged one-shot call | -| [custom-pagination](./custom-pagination) | CLI · `sdk` | hand-written paging over the typed client for shapes the built-in styles don't cover (body cursors) | -| [custom-generator](./custom-generator) | CLI · `sdk` + custom generator | a local `generators` plugin emitting a `: 'METHOD /path'` route map next to the sdk | -| [ast-toolkit-generator](./ast-toolkit-generator) | CLI · `sdk` + custom generator | a plugin emitting real TypeScript AST via `@redocly/client-generator/generate` (`schemaToTypeNode`, `printStatements`) — a typed response-shape map | -| [nested-facade](./nested-facade) | CLI · `sdk` + custom generator | `api..` facade derived from the spec's tags by a plugin — regenerates with the spec | -| [cli](./cli) | CLI · `sdk`, `zod`, `cli` | a bin-ready command-line interface over the client: typed flags, `--json` bodies, `--dry-run`, a documented exit-code contract | -| [python-sdk](./python-sdk) | CLI · `python` | a full Python SDK (httpx): typed dataclasses, sync/async clients, pagination iterators | -| [go-sdk](./go-sdk) | CLI · `go` | a full Go SDK (stdlib-only): typed structs, `(T, error)` methods, `context.Context` | -| [php-sdk](./php-sdk) | CLI · `php` | a full PHP SDK (curl extension): promoted-constructor classes, native enums, named-argument methods | -| [ejected-generator](./ejected-generator) | CLI · ejected `php` | `eject-generator php` vendored + customized: the path entry shadows the built-in name; regeneration keeps the customization | -| [scaffolded-generator](./scaffolded-generator) | CLI · `sdk` + scaffolded | `scaffold-generator` skeleton filled in — a markdown operations summary emitted next to the client | +| Example | How it's generated | Shows | +| ------------------------------------------------------ | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | +| [fetch-functions](./fetch-functions) | CLI · `sdk`, functions | free functions + `ApiError` | +| [baked-setup](./baked-setup) | CLI · `sdk`, functions | publisher defaults baked into the client via `--setup` (`defineClientSetup`) | +| [zod](./zod) | CLI · `sdk`, `zod` | validating responses with generated zod schemas | +| [tanstack-query](./tanstack-query) | CLI · `sdk`, `tanstack-query` | React `useQuery(Options())` | +| [mock](./mock) | CLI · `sdk`, `mock` | MSW handlers from generated `handlers` | +| [programmatic](./programmatic) | `generateClient(...)` API | generating the client from a Node script | +| [package-runtime](./package-runtime) | CLI · `sdk`, package runtime | `runtime: package` — types + descriptors only; the versioned runtime is imported from `@redocly/client-generator`, fixes via `npm update` | +| [zero-install-quickstart](./zero-install-quickstart) | CLI · `sdk` | the first-touch loop: generate → import → call; one self-contained file, zero runtime dependencies | +| [node-native](./node-native) | CLI · `sdk` | `importExt: ts` — `.ts` import specifiers so plain `node src/main.ts` runs the client via Node's built-in type stripping | +| [configure-and-middleware](./configure-and-middleware) | CLI · `sdk` | `configure({ serverUrl, retry, fetch })`, `use()` targeting `ctx.operation` (literal unions), body mutation, auth setter, `ApiError.body` | +| [multi-instance](./multi-instance) | CLI · `sdk`, package runtime | per-tenant instances via `createClient(OPERATIONS)` — works in both runtimes; this example uses `runtime: package` | +| [sse-streaming](./sse-streaming) | CLI · `sdk` | typed `for await` over SSE, auto-reconnect via `Last-Event-ID` (`reconnectDelay`/`reconnect: false`), clean abort | +| [vendored-edge](./vendored-edge) | CLI · `sdk` | the generated file copied into a no-npm edge worker (`export default { fetch }`); `typescript` is the only dev tool | +| [pagination](./pagination) | CLI · `sdk` | auto-pagination from a `client.pagination` convention: `for await` over `.items()`/`.pages()` next to the unchanged one-shot call | +| [custom-pagination](./custom-pagination) | CLI · `sdk` | hand-written paging over the typed client for shapes the built-in styles don't cover (body cursors) | +| [custom-generator](./custom-generator) | CLI · `sdk` + custom generator | a local `generators` plugin emitting a `: 'METHOD /path'` route map next to the sdk | +| [ast-toolkit-generator](./ast-toolkit-generator) | CLI · `sdk` + custom generator | a plugin rendering real TypeScript types via `@redocly/client-generator/generate` (`tsType`) — a typed response-shape map | +| [nested-facade](./nested-facade) | CLI · `sdk` + custom generator | `api..` facade derived from the spec's tags by a plugin — regenerates with the spec | +| [cli](./cli) | CLI · `sdk`, `zod`, `cli` | a bin-ready command-line interface over the client: typed flags, `--json` bodies, `--dry-run`, a documented exit-code contract | +| [python-sdk](./python-sdk) | CLI · `python` | a full Python SDK (httpx): typed dataclasses, sync/async clients, pagination iterators | +| [go-sdk](./go-sdk) | CLI · `go` | a full Go SDK (stdlib-only): typed structs, `(T, error)` methods, `context.Context` | +| [php-sdk](./php-sdk) | CLI · `php` | a full PHP SDK (curl extension): promoted-constructor classes, native enums, named-argument methods | +| [ejected-generator](./ejected-generator) | CLI · ejected `php` | `eject-generator php` vendored + customized: the path entry shadows the built-in name; regeneration keeps the customization | +| [scaffolded-generator](./scaffolded-generator) | CLI · `sdk` + scaffolded | `scaffold-generator` skeleton filled in — a markdown operations summary emitted next to the client | ## Run one diff --git a/tests/e2e/generate-client/examples/ast-toolkit-generator/README.md b/tests/e2e/generate-client/examples/ast-toolkit-generator/README.md index ed1c1b7a12..ae939d258b 100644 --- a/tests/e2e/generate-client/examples/ast-toolkit-generator/README.md +++ b/tests/e2e/generate-client/examples/ast-toolkit-generator/README.md @@ -1,13 +1,13 @@ # AST toolkit generator example A custom generator that builds its output as a real TypeScript AST with the -`@redocly/client-generator/generate` entry — the same `ts.factory` + printer toolkit the built-in +`@redocly/client-generator/generate` entry — the same type-rendering toolkit the built-in generators use — instead of concatenating strings (compare with the string-building [`custom-generator`](../custom-generator) example). - [`response-map-generator.mjs`](./response-map-generator.mjs) — the generator. For every operation with a JSON success response it derives the response body's TypeScript type - with `schemaToTypeNode` and prints `src/api/client.responses.ts`: + with `tsType` and renders `src/api/client.responses.ts`: ```ts import type { MenuItemList, Order, OrderItem } from './client.js'; @@ -31,8 +31,8 @@ runtime-only. The `/generate` entry holds everything that runs at **generation time** — it loads the TypeScript compiler and `@redocly/openapi-core`, which an app must never pull in: -- the emit toolkit used here (`ts`, `printStatements`, `parseStatements`, `operationSignature`, - `schemaToTypeNode`, `pascalCase`, …), +- the text toolkit used here (`tsType`, `tsJsdoc`, `codeLiteral`, `operationSignature`, + `pascalCase`, …), - `generateClient` (also re-exported from the root behind a dynamic import) and `collectGeneratedFiles` for in-memory generation. diff --git a/tests/e2e/generate-client/examples/ast-toolkit-generator/response-map-generator.mjs b/tests/e2e/generate-client/examples/ast-toolkit-generator/response-map-generator.mjs index b99efafd41..b753f643ba 100644 --- a/tests/e2e/generate-client/examples/ast-toolkit-generator/response-map-generator.mjs +++ b/tests/e2e/generate-client/examples/ast-toolkit-generator/response-map-generator.mjs @@ -1,19 +1,16 @@ -// A custom generator that builds its output as a real TypeScript AST with the -// `@redocly/client-generator/generate` toolkit — the same `ts.factory` + printer the -// built-in generators use — instead of concatenating strings. It emits -// `.responses.ts`: a `ResponseShapes` type mapping every operation to the -// TypeScript type of its primary JSON success body. `schemaToTypeNode` does the -// schema→type mapping (refs, arrays, unions, formats) exactly as the sdk does, and the -// printer gets quoting and type syntax right for free. +// A custom generator that renders real TypeScript TYPES with the +// `@redocly/client-generator/generate` text toolkit — `tsType` is the same +// schema→type renderer the built-in sdk uses (refs, arrays, unions, formats, +// parenthesization), so the output matches the generated client's types exactly. +// It emits `.responses.ts`: a `ResponseShapes` type mapping every +// operation to the TypeScript type of its primary JSON success body. // // Plain ESM so the CLI imports it under bare `node`. Authored in TypeScript you would write: // // import { defineGenerator } from '@redocly/client-generator'; -// import { printStatements, schemaToTypeNode, ts } from '@redocly/client-generator/generate'; +// import { tsType } from '@redocly/client-generator/generate'; // export default defineGenerator({ name: 'response-map', requires: ['sdk'], run({ model, outputPath }) { … } }); -import { printStatements, schemaToTypeNode, ts } from '@redocly/client-generator/generate'; - -const { factory } = ts; +import { tsType } from '@redocly/client-generator/generate'; export default { name: 'response-map', @@ -29,18 +26,11 @@ export default { return success ? [{ name: op.name, schema: success.schema }] : []; }); - const responseShapes = factory.createTypeAliasDeclaration( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - 'ResponseShapes', - undefined, - factory.createTypeLiteralNode( - withJsonBody.map(({ name, schema }) => - factory.createPropertySignature(undefined, name, undefined, schemaToTypeNode(schema)) - ) - ) + const members = withJsonBody.map( + ({ name, schema }) => ` ${name}: ${tsType(schema, 'string', ' ')};` ); - // `schemaToTypeNode` renders a `$ref` as a bare type reference, so the module + // `tsType` renders a `$ref` as a bare type reference, so the module // type-imports every referenced schema name from the generated client. const referenced = [...new Set(withJsonBody.flatMap(({ schema }) => refNames(schema)))].sort(); const importLine = @@ -54,7 +44,7 @@ export default { content: '// Generated by the response-map custom generator. Do not edit by hand.\n' + importLine + - printStatements([responseShapes]) + + `export type ResponseShapes = {\n${members.join('\n')}\n};` + '\n', }, ]; diff --git a/tests/e2e/generate-client/examples/custom-generator/route-map-generator.mjs b/tests/e2e/generate-client/examples/custom-generator/route-map-generator.mjs index f8a9133b66..6b404a4467 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 @@ -2,57 +2,27 @@ // redocly.yaml as a path specifier, it runs alongside the built-in `sdk` and emits a // `.routes.ts` map of every operation — `: 'METHOD /path'`. // -// The output is built as a real TypeScript AST with the `@redocly/client-generator/generate` -// toolkit — the same `ts.factory` + printer the built-in generators use — so quoting and -// formatting come out right for free. Plain ESM so the CLI imports it under bare `node`. -// Authored in TypeScript you would write: +// The output is a source-text template — the same authoring model every built-in +// generator uses. Plain ESM so the CLI imports it under bare `node`. Authored in +// TypeScript you would write: // // import { defineGenerator } from '@redocly/client-generator'; -// import { printStatements, ts } from '@redocly/client-generator/generate'; // export default defineGenerator({ name: 'route-map', requires: ['sdk'], run({ model, outputPath }) { … } }); // // `defineGenerator` is just an identity helper for types, so a plain object works too: -import { printStatements, ts } from '@redocly/client-generator/generate'; - -const { factory } = ts; - export default { name: 'route-map', requires: ['sdk'], run({ model, outputPath }) { const entries = model.services .flatMap((service) => service.operations) - .map((op) => - factory.createPropertyAssignment( - op.name, - factory.createStringLiteral(`${op.method.toUpperCase()} ${op.path}`, true) - ) - ); - // export const routes = { … } as const; - const routes = factory.createVariableStatement( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - factory.createVariableDeclarationList( - [ - factory.createVariableDeclaration( - 'routes', - undefined, - undefined, - factory.createAsExpression( - factory.createObjectLiteralExpression(entries, true), - factory.createTypeReferenceNode('const') - ) - ), - ], - ts.NodeFlags.Const - ) - ); + .map((op) => ` ${op.name}: '${op.method.toUpperCase()} ${op.path}',`); return [ { path: outputPath.replace(/\.ts$/, '.routes.ts'), content: '// Generated by the route-map custom generator. Do not edit by hand.\n' + - printStatements([routes]) + - '\n', + `export const routes = {\n${entries.join('\n')}\n} as const;\n`, }, ]; }, From fdd5b30320fe9c0bf381b6574064a5fbb22e4fec Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 15:48:55 +0300 Subject: [PATCH 058/211] =?UTF-8?q?feat(client-generator)!:=20rename=20Cod?= =?UTF-8?q?eWriter=20to=20Printer=20=E2=80=94=20the=20shared=20print=20eng?= =?UTF-8?q?ine=20of=20every=20generator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/@v2/commands/scaffold-generator.md | 2 +- docs/@v2/guides/customize-client-generation.md | 2 +- .../generate-client-telemetry.test.ts | 16 +++++++++------- .../cli/src/commands/scaffold-generator.ts | 4 ++-- .../client-generator/eject-assets/AGENTS.md | 4 ++-- .../{code-writer.test.ts => printer.test.ts} | 10 +++++----- .../client-generator/src/authoring/index.ts | 4 ++-- .../authoring/{code-writer.ts => printer.ts} | 2 +- packages/client-generator/src/generators/go.ts | 14 +++++++------- .../client-generator/src/generators/php.ts | 16 ++++++++-------- .../client-generator/src/generators/python.ts | 18 +++++++++--------- .../generators/.pristine/php.mjs | 6 +++--- .../ejected-generator/generators/AGENTS.md | 4 ++-- .../ejected-generator/generators/php.mjs | 6 +++--- .../scaffolded-generator/generators/AGENTS.md | 4 ++-- .../generators/ops-summary.mjs | 4 ++-- 16 files changed, 59 insertions(+), 57 deletions(-) rename packages/client-generator/src/authoring/__tests__/{code-writer.test.ts => printer.test.ts} (83%) rename packages/client-generator/src/authoring/{code-writer.ts => printer.ts} (97%) diff --git a/docs/@v2/commands/scaffold-generator.md b/docs/@v2/commands/scaffold-generator.md index ef61c84aa0..cbfb99a3dd 100644 --- a/docs/@v2/commands/scaffold-generator.md +++ b/docs/@v2/commands/scaffold-generator.md @@ -22,7 +22,7 @@ redocly scaffold-generator my-sdk --dir ./generators ## How it works The skeleton is a runnable generator: it walks every operation of the API description and emits one file. -Replace its body with your output logic — the `CodeWriter`, naming, and schema helpers from `@redocly/client-generator` (installed once as a dev dependency) handle indentation, identifier sanitization, and schema semantics in any output language. +Replace its body with your output logic — the `Printer`, naming, and schema helpers from `@redocly/client-generator` (installed once as a dev dependency) handle indentation, identifier sanitization, and schema semantics in any output language. ```yaml client: diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index f4d6631482..3451af257d 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -91,7 +91,7 @@ The package root exports pure helpers over the API model that cover the cross-la | `isNullable(schema)` / `unwrapNullable(schema)` | Detect and strip `null` union members (`Optional[T]`, pointers, `Option`). | | `enumValues(schema)` | Values plus SCREAMING_SNAKE member-name suggestions. | | `casing` / `identifierFor(name, opts)` | camel/pascal/snake/screaming casing; keyword-safe identifiers (`RESERVED_WORDS.python/go/typescript` shipped, pass your own set). | -| `CodeWriter` | Indentation-aware text builder — no manual whitespace bookkeeping. | +| `Printer` | Indentation-aware text builder — no manual whitespace bookkeeping. | | `docText(description)` | Description text 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 applying to an operation (per-op config > `x-redocly-pagination` > fitting convention), normalized. | diff --git a/packages/cli/src/__tests__/generate-client-telemetry.test.ts b/packages/cli/src/__tests__/generate-client-telemetry.test.ts index 75deb3b99d..2de75a73d5 100644 --- a/packages/cli/src/__tests__/generate-client-telemetry.test.ts +++ b/packages/cli/src/__tests__/generate-client-telemetry.test.ts @@ -8,21 +8,23 @@ import { describe('collectToolkitImports', () => { it('returns only OUR helper names from client-generator imports — never user identifiers', () => { const source = [ - "import { flattenAllOf, CodeWriter, mySecretHelper } from '@redocly/client-generator';", + "import { flattenAllOf, Printer, mySecretHelper } from '@redocly/client-generator';", "import { printStatements } from '@redocly/client-generator/generate';", "import { internalThing } from './our-private-module.js';", ].join('\n'); - expect( - collectToolkitImports(source, ['flattenAllOf', 'CodeWriter', 'printStatements']) - ).toEqual(['flattenAllOf', 'CodeWriter', 'printStatements']); + expect(collectToolkitImports(source, ['flattenAllOf', 'Printer', 'printStatements'])).toEqual([ + 'flattenAllOf', + 'Printer', + 'printStatements', + ]); }); it('handles aliased and type-only named imports', () => { const source = - "import { type flattenAllOf, CodeWriter as Writer } from '@redocly/client-generator';"; - expect(collectToolkitImports(source, ['flattenAllOf', 'CodeWriter'])).toEqual([ + "import { type flattenAllOf, Printer as Writer } from '@redocly/client-generator';"; + expect(collectToolkitImports(source, ['flattenAllOf', 'Printer'])).toEqual([ 'flattenAllOf', - 'CodeWriter', + 'Printer', ]); }); }); diff --git a/packages/cli/src/commands/scaffold-generator.ts b/packages/cli/src/commands/scaffold-generator.ts index 6004439a0c..548a1a8e34 100644 --- a/packages/cli/src/commands/scaffold-generator.ts +++ b/packages/cli/src/commands/scaffold-generator.ts @@ -33,7 +33,7 @@ function skeleton(name: string): string { // It runs from the \`generators\` list in redocly.yaml and emits files next to the // configured client output. The authoring guide for your agent is in ./AGENTS.md; // the deep reference is the "Customize client generation" guide in the Redocly docs. -import { CodeWriter, identifierFor } from '@redocly/client-generator'; +import { Printer, identifierFor } from '@redocly/client-generator'; export default { name: '${name}', @@ -43,7 +43,7 @@ export default { * @returns {{ path: string, content: string }[]} */ run({ model, outputPath }) { - const writer = new CodeWriter(' '); + const writer = new Printer(' '); writer.line(\`// \${model.title} \${model.version} — generated by the "${name}" generator.\`); for (const service of model.services) { for (const op of service.operations) { diff --git a/packages/client-generator/eject-assets/AGENTS.md b/packages/client-generator/eject-assets/AGENTS.md index abe2e6a1b4..1c1321abfc 100644 --- a/packages/client-generator/eject-assets/AGENTS.md +++ b/packages/client-generator/eject-assets/AGENTS.md @@ -49,7 +49,7 @@ discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, | `isNullable(schema)` / `unwrapNullable(schema)` | Detect and strip `null` union members (`Optional[T]`, pointers, `Option`). | | `enumValues(schema)` | Values plus SCREAMING_SNAKE member-name suggestions. | | `casing` / `identifierFor(name, { style, reserved })` | camel/pascal/snake/screaming; keyword-safe identifiers (`RESERVED_WORDS.python/go/typescript` shipped). | -| `CodeWriter` | Indentation-aware text builder — no manual whitespace bookkeeping. | +| `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. | @@ -58,7 +58,7 @@ discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, Worked example: the built-in `python` generator (`packages/client-generator/src/generators/python.ts` in the Redocly CLI repo) is authored with exactly this toolkit and nothing else — models via `flattenAllOf`/ -`enumValues`/`discriminatorCases`, all code through `CodeWriter`, every name through +`enumValues`/`discriminatorCases`, all code through `Printer`, every name through `identifierFor(..., RESERVED_WORDS.python)`. TypeScript-emitting generators may additionally use the TS toolkit from diff --git a/packages/client-generator/src/authoring/__tests__/code-writer.test.ts b/packages/client-generator/src/authoring/__tests__/printer.test.ts similarity index 83% rename from packages/client-generator/src/authoring/__tests__/code-writer.test.ts rename to packages/client-generator/src/authoring/__tests__/printer.test.ts index 9cdecbbf6d..88ff9b04e3 100644 --- a/packages/client-generator/src/authoring/__tests__/code-writer.test.ts +++ b/packages/client-generator/src/authoring/__tests__/printer.test.ts @@ -1,8 +1,8 @@ -import { CodeWriter } from '../code-writer.js'; +import { Printer } from '../printer.js'; -describe('CodeWriter', () => { +describe('Printer', () => { it('builds indented blocks in any language without manual whitespace bookkeeping', () => { - const writer = new CodeWriter(); + const writer = new Printer(); writer.line('class Pet:').indent(() => { writer.line('def __init__(self):').indent(() => { writer.line('self.name = name'); @@ -12,7 +12,7 @@ describe('CodeWriter', () => { }); it('block() without a close suits dedent-terminated languages (Python)', () => { - const writer = new CodeWriter(' '); + const writer = new Printer(' '); writer.block('class Pet:', () => { writer.line('name: str'); }); @@ -21,7 +21,7 @@ describe('CodeWriter', () => { }); it('block() wraps open/body/close; blank() emits an empty line without indentation', () => { - const writer = new CodeWriter(' '); + const writer = new Printer(' '); writer.block( 'func main() {', () => { diff --git a/packages/client-generator/src/authoring/index.ts b/packages/client-generator/src/authoring/index.ts index cf296ab968..7eb6d18f88 100644 --- a/packages/client-generator/src/authoring/index.ts +++ b/packages/client-generator/src/authoring/index.ts @@ -2,7 +2,7 @@ // no typescript, no @redocly/openapi-core, no Node builtins — so it is exported // from the package ROOT: a custom generator importing only these stays TS-free. -export { CodeWriter } from './code-writer.js'; +export { Printer } from './printer.js'; export { casing, identifierFor, RESERVED_WORDS } from './naming.js'; export { paginationRuleFor, type NeutralPaginationRule } from './pagination.js'; export { @@ -17,7 +17,7 @@ export { /** Every value exported above — the skill's helper table and Tier-2 telemetry key off this. */ export const AUTHORING_HELPER_NAMES = [ - 'CodeWriter', + 'Printer', 'casing', 'identifierFor', 'RESERVED_WORDS', diff --git a/packages/client-generator/src/authoring/code-writer.ts b/packages/client-generator/src/authoring/printer.ts similarity index 97% rename from packages/client-generator/src/authoring/code-writer.ts rename to packages/client-generator/src/authoring/printer.ts index 94bde3e10d..927883015a 100644 --- a/packages/client-generator/src/authoring/code-writer.ts +++ b/packages/client-generator/src/authoring/printer.ts @@ -1,7 +1,7 @@ // A small indentation-aware text builder for emitting code in ANY language — // deliberately not an AST. Part of the language-neutral authoring toolkit. -export class CodeWriter { +export class Printer { private readonly lines: string[] = []; private depth = 0; diff --git a/packages/client-generator/src/generators/go.ts b/packages/client-generator/src/generators/go.ts index 2018096d7f..c9193b4b3b 100644 --- a/packages/client-generator/src/generators/go.ts +++ b/packages/client-generator/src/generators/go.ts @@ -6,7 +6,7 @@ import { casing, - CodeWriter, + Printer, discriminatorCases, docText, enumValues, @@ -79,7 +79,7 @@ export function goType(schema: SchemaModel): string { } } -function writeDocComment(writer: CodeWriter, name: string, description?: string): void { +function writeDocComment(writer: Printer, name: string, description?: string): void { const lines = docText(description); if (lines.length === 0) return; writer.line(`// ${name} — ${lines[0]}`); @@ -87,7 +87,7 @@ function writeDocComment(writer: CodeWriter, name: string, description?: string) } function writeStruct( - writer: CodeWriter, + writer: Printer, name: string, properties: PropertyModel[], description?: string @@ -121,7 +121,7 @@ function writeStruct( /** Render every named schema: typed-const enums, structs (allOf flattened), union dispatchers. */ export function renderGoModels(model: ApiModel): string { - const writer = new CodeWriter('\t'); + const writer = new Printer('\t'); writer.line('package client'); writer.blank(); const needsJSON = model.schemas.some( @@ -325,7 +325,7 @@ function goPaginationLiteral(rule: NeutralPaginationRule): string { return `&PaginationSpec{${fields.join(', ')}}`; } -function writeGoMethod(writer: CodeWriter, op: OperationModel, ident: string): void { +function writeGoMethod(writer: Printer, op: OperationModel, ident: string): void { const pathArgs = op.pathParams.map((param) => ({ param, go: identifierFor(param.name, { style: 'camel', reserved: GO }), @@ -477,7 +477,7 @@ function writeGoMethod(writer: CodeWriter, op: OperationModel, ident: string): v /** `Pages` / `Items` iterators over the runtime's `iterPages`, hydrated via `reencode`. */ function writeGoPaginationWrappers( - writer: CodeWriter, + writer: Printer, op: OperationModel, ident: string, pageType: string, @@ -662,7 +662,7 @@ function writeGoPaginationWrappers( /** The whole generated file: models + embedded runtime + operations table + Client. */ export const goGenerator: Generator = ({ model, outputPath, emit }) => { - const writer = new CodeWriter('\t'); + const writer = new Printer('\t'); const paginationRules = new Map(); for (const { op, ident } of goOperationIdents(model)) { const rule = paginationRuleFor(op, emit.pagination as Record | undefined); diff --git a/packages/client-generator/src/generators/php.ts b/packages/client-generator/src/generators/php.ts index 2638536776..9fd96be2a8 100644 --- a/packages/client-generator/src/generators/php.ts +++ b/packages/client-generator/src/generators/php.ts @@ -6,7 +6,7 @@ // embedded runtime. Exceptions are the error mode (`errorMode` does not apply). import { - CodeWriter, + Printer, docText, discriminatorCases, enumValues, @@ -159,14 +159,14 @@ function serialization(schema: SchemaModel, expr: string, model: ApiModel): stri return undefined; } -function writeDocComment(writer: CodeWriter, name: string, description?: string): void { +function writeDocComment(writer: Printer, name: string, description?: string): void { const lines = docText(description); if (lines.length === 0) return; writer.line(`/** ${name} — ${lines.join(' ')} */`); } function writeClass( - writer: CodeWriter, + writer: Printer, name: string, properties: PropertyModel[], model: ApiModel, @@ -259,7 +259,7 @@ function writeClass( /** Render every named schema: classes (allOf flattened), native enums, union dispatchers. */ export function renderPhpModels(model: ApiModel): string { - const writer = new CodeWriter(' '); + const writer = new Printer(' '); for (const { name, schema } of model.schemas) { const asEnum = enumValues(schema); if (asEnum !== undefined && (asEnum.scalar === 'string' || asEnum.scalar === 'integer')) { @@ -424,7 +424,7 @@ function methodArgs(op: OperationModel, model: ApiModel, includeBody: boolean): } /** The shared prologue: resolve auth, build query/url, merge headers. */ -function writeRequestSetup(writer: CodeWriter, op: OperationModel, args: MethodArgs): void { +function writeRequestSetup(writer: Printer, op: OperationModel, args: MethodArgs): void { writer.line(`$op = OPERATIONS[${phpString(op.specName ?? op.name)}];`); writer.line( "[$authHeaders, $query, $cookies] = resolveAuth($op['security'] ?? [], $this->config->auth);" @@ -452,7 +452,7 @@ function writeRequestSetup(writer: CodeWriter, op: OperationModel, args: MethodA ); } -function writePhpMethod(writer: CodeWriter, op: OperationModel, model: ApiModel): void { +function writePhpMethod(writer: Printer, op: OperationModel, model: ApiModel): void { const args = methodArgs(op, model, true); const sse = sseResponse(op); const success = successSchema(op); @@ -535,7 +535,7 @@ function writePhpMethod(writer: CodeWriter, op: OperationModel, model: ApiModel) /** `Pages()` / `Items()` generators over the runtime's iterPages. */ function writePhpPaginationWrappers( - writer: CodeWriter, + writer: Printer, op: OperationModel, model: ApiModel, pageHydration: string | undefined, @@ -659,7 +659,7 @@ function stripPhpHeader(source: string): string { /** The whole generated file: namespace + models + embedded runtime + operations + Client. */ export const phpGenerator: Generator = ({ model, outputPath, emit }) => { - const writer = new CodeWriter(' '); + const writer = new Printer(' '); const namespace = identifierFor(model.title, { style: 'pascal', reserved: PHP }); writer.line('_pages` / `_items` iterator methods for a paginated operation. */ function writePaginationWrappers( - writer: CodeWriter, + writer: Printer, op: OperationModel, ident: string, isAsync: boolean, @@ -456,7 +456,7 @@ function writePaginationWrappers( } function writeClientClass( - writer: CodeWriter, + writer: Printer, model: ApiModel, errorMode: 'throw' | 'result', isAsync: boolean, @@ -519,7 +519,7 @@ function writeClientClass( /** The whole generated file: header, models, embedded runtime, descriptors, clients. */ export const pythonGenerator: Generator = ({ model, outputPath, emit }) => { const errorMode = emit.errorMode ?? 'throw'; - const writer = new CodeWriter(' '); + const writer = new Printer(' '); writer.line( `# Generated by @redocly/client-generator (python) from "${model.title}" ${model.version}.` ); diff --git a/tests/e2e/generate-client/examples/ejected-generator/generators/.pristine/php.mjs b/tests/e2e/generate-client/examples/ejected-generator/generators/.pristine/php.mjs index f9007853ba..f68db5e5f8 100644 --- a/tests/e2e/generate-client/examples/ejected-generator/generators/.pristine/php.mjs +++ b/tests/e2e/generate-client/examples/ejected-generator/generators/.pristine/php.mjs @@ -8,7 +8,7 @@ // 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 { CodeWriter, docText, discriminatorCases, enumValues, flattenAllOf, identifierFor, isNullable, paginationRuleFor, RESERVED_WORDS, schemaAtPointer, unwrapNullable, } from '@redocly/client-generator'; +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) { @@ -214,7 +214,7 @@ function writeClass(writer, name, properties, model, description) { } /** Render every named schema: classes (allOf flattened), native enums, union dispatchers. */ export function renderPhpModels(model) { - const writer = new CodeWriter(' '); + const writer = new Printer(' '); for (const { name, schema } of model.schemas) { const asEnum = enumValues(schema); if (asEnum !== undefined && (asEnum.scalar === 'string' || asEnum.scalar === 'integer')) { @@ -485,7 +485,7 @@ function stripPhpHeader(source) { } /** The whole generated file: namespace + models + embedded runtime + operations + Client. */ export const phpGenerator = ({ model, outputPath, emit }) => { - const writer = new CodeWriter(' '); + const writer = new Printer(' '); const namespace = identifierFor(model.title, { style: 'pascal', reserved: PHP }); writer.line('`). | | `enumValues(schema)` | Values plus SCREAMING_SNAKE member-name suggestions. | | `casing` / `identifierFor(name, { style, reserved })` | camel/pascal/snake/screaming; keyword-safe identifiers (`RESERVED_WORDS.python/go/typescript` shipped). | -| `CodeWriter` | Indentation-aware text builder — no manual whitespace bookkeeping. | +| `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. | @@ -60,7 +60,7 @@ discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, Worked example: the built-in `python` generator (`packages/client-generator/src/generators/python.ts` in the Redocly CLI repo) is authored with exactly this toolkit and nothing else — models via `flattenAllOf`/ -`enumValues`/`discriminatorCases`, all code through `CodeWriter`, every name through +`enumValues`/`discriminatorCases`, all code through `Printer`, every name through `identifierFor(..., RESERVED_WORDS.python)`. TypeScript-emitting generators may additionally use the TS toolkit from 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 37ba9b8d8b..608f9abdec 100644 --- a/tests/e2e/generate-client/examples/ejected-generator/generators/php.mjs +++ b/tests/e2e/generate-client/examples/ejected-generator/generators/php.mjs @@ -8,7 +8,7 @@ // 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 { CodeWriter, docText, discriminatorCases, enumValues, flattenAllOf, identifierFor, isNullable, paginationRuleFor, RESERVED_WORDS, schemaAtPointer, unwrapNullable, } from '@redocly/client-generator'; +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) { @@ -214,7 +214,7 @@ function writeClass(writer, name, properties, model, description) { } /** Render every named schema: classes (allOf flattened), native enums, union dispatchers. */ export function renderPhpModels(model) { - const writer = new CodeWriter(' '); + const writer = new Printer(' '); for (const { name, schema } of model.schemas) { const asEnum = enumValues(schema); if (asEnum !== undefined && (asEnum.scalar === 'string' || asEnum.scalar === 'integer')) { @@ -485,7 +485,7 @@ function stripPhpHeader(source) { } /** The whole generated file: namespace + models + embedded runtime + operations + Client. */ export const phpGenerator = ({ model, outputPath, emit }) => { - const writer = new CodeWriter(' '); + const writer = new Printer(' '); const namespace = identifierFor(model.title, { style: 'pascal', reserved: PHP }); writer.line('`). | | `enumValues(schema)` | Values plus SCREAMING_SNAKE member-name suggestions. | | `casing` / `identifierFor(name, { style, reserved })` | camel/pascal/snake/screaming; keyword-safe identifiers (`RESERVED_WORDS.python/go/typescript` shipped). | -| `CodeWriter` | Indentation-aware text builder — no manual whitespace bookkeeping. | +| `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. | @@ -60,7 +60,7 @@ discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, Worked example: the built-in `python` generator (`packages/client-generator/src/generators/python.ts` in the Redocly CLI repo) is authored with exactly this toolkit and nothing else — models via `flattenAllOf`/ -`enumValues`/`discriminatorCases`, all code through `CodeWriter`, every name through +`enumValues`/`discriminatorCases`, all code through `Printer`, every name through `identifierFor(..., RESERVED_WORDS.python)`. TypeScript-emitting generators may additionally use the TS toolkit from diff --git a/tests/e2e/generate-client/examples/scaffolded-generator/generators/ops-summary.mjs b/tests/e2e/generate-client/examples/scaffolded-generator/generators/ops-summary.mjs index f65b16377b..c7a92a87b9 100644 --- a/tests/e2e/generate-client/examples/scaffolded-generator/generators/ops-summary.mjs +++ b/tests/e2e/generate-client/examples/scaffolded-generator/generators/ops-summary.mjs @@ -2,12 +2,12 @@ // emits a markdown operations summary next to the client — an artifact no // built-in generator covers, derived from the same API model, so it can // never drift from the description. -import { CodeWriter } from '@redocly/client-generator'; +import { Printer } from '@redocly/client-generator'; export default { name: 'ops-summary', run({ model, outputPath }) { - const writer = new CodeWriter(' '); + const writer = new Printer(' '); writer.line(`# ${model.title} ${model.version} — operations`); writer.blank(); writer.line('| Operation | Method | Path | Summary |'); From 6ed27e18a3c2b99b2e2ac364e4ac5a7bfe50e71a Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 15:56:51 +0300 Subject: [PATCH 059/211] feat(client-generator): generator-per-folder with its own AGENTS.md skill, shipped at eject --- packages/cli/src/commands/eject-generator.ts | 12 +++++ .../scripts/generate-eject-assets.mjs | 15 ++++-- .../__tests__/generator-skills.test.ts | 24 ++++++++++ .../src/generators/__tests__/go.test.ts | 2 +- .../__tests__/language-dogfooding.test.ts | 39 ++++++++------- .../src/generators/__tests__/php.test.ts | 2 +- .../src/generators/__tests__/python.test.ts | 2 +- .../src/generators/go/AGENTS.md | 42 +++++++++++++++++ .../src/generators/{go.ts => go/index.ts} | 8 ++-- .../client-generator/src/generators/index.ts | 6 +-- .../client-generator/src/generators/meta.ts | 7 +-- .../src/generators/php/AGENTS.md | 47 +++++++++++++++++++ .../src/generators/{php.ts => php/index.ts} | 8 ++-- .../src/generators/python/AGENTS.md | 42 +++++++++++++++++ .../generators/{python.ts => python/index.ts} | 8 ++-- tests/e2e/generate-client/eject.test.ts | 5 ++ .../generators/php.AGENTS.md | 47 +++++++++++++++++++ 17 files changed, 273 insertions(+), 43 deletions(-) create mode 100644 packages/client-generator/src/generators/__tests__/generator-skills.test.ts create mode 100644 packages/client-generator/src/generators/go/AGENTS.md rename packages/client-generator/src/generators/{go.ts => go/index.ts} (99%) create mode 100644 packages/client-generator/src/generators/php/AGENTS.md rename packages/client-generator/src/generators/{php.ts => php/index.ts} (99%) create mode 100644 packages/client-generator/src/generators/python/AGENTS.md rename packages/client-generator/src/generators/{python.ts => python/index.ts} (98%) create mode 100644 tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts index cb6c03abed..dd856d4d5d 100644 --- a/packages/cli/src/commands/eject-generator.ts +++ b/packages/cli/src/commands/eject-generator.ts @@ -64,6 +64,16 @@ function dropAgentsSkill(dir: string, assetsDir: string): void { ); } +/** The generator's OWN design skill, refreshed on every eject/update (it documents OUR + * generator; user notes belong outside it). Dropped as `generators/.AGENTS.md`. */ +function dropGeneratorSkill(dir: string, assetsDir: string, name: string): void { + writeFileSync( + join(dir, `${name}.AGENTS.md`), + readFileSync(join(assetsDir, 'generators', `${name}.AGENTS.md`), 'utf-8'), + 'utf-8' + ); +} + /** 3-way merge via `git merge-file`; returns the merged text and the conflict count. */ function threeWayMerge( customized: string, @@ -157,6 +167,7 @@ export const handleEjectGenerator = async ({ argv }: CommandArgs 0 ? 'conflicts' : 'success'; if (conflicts > 0) { ejectGeneratorTelemetry.eject_generator_conflicts = conflicts; @@ -179,6 +190,7 @@ export const handleEjectGenerator = async ({ argv }: CommandArgs.AGENTS.md` so the agent that edits the ejected file + // starts from the generator's design, not from reverse-engineering it. + copyFileSync( + join(pkgRoot, 'src', 'generators', name, 'AGENTS.md'), + join(outDir, `${name}.AGENTS.md`) + ); } diff --git a/packages/client-generator/src/generators/__tests__/generator-skills.test.ts b/packages/client-generator/src/generators/__tests__/generator-skills.test.ts new file mode 100644 index 0000000000..157633a2ff --- /dev/null +++ b/packages/client-generator/src/generators/__tests__/generator-skills.test.ts @@ -0,0 +1,24 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Skill-first development: every language generator lives in a folder with its own +// AGENTS.md — the design the code must match (and the file eject ships to users). +// A generator folder without a skill, or a skill missing its modify-loop anchors, +// fails here. +const generatorsDir = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +describe.each(['python', 'go', 'php'])('%s generator skill', (name) => { + const skillPath = join(generatorsDir, name, 'AGENTS.md'); + + it('exists next to the generator', () => { + expect(existsSync(skillPath)).toBe(true); + }); + + it('names its runtime, the skill-first rule, and the verify loop', () => { + const skill = readFileSync(skillPath, 'utf-8'); + expect(skill).toContain(`${name}-runtime/`); + expect(skill).toContain('edit this skill first'); + expect(skill).toContain('npm run harness'); + }); +}); diff --git a/packages/client-generator/src/generators/__tests__/go.test.ts b/packages/client-generator/src/generators/__tests__/go.test.ts index 16fc64eb9c..ccb5e24257 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.js'; +import { goGenerator, renderGoModels } from '../go/index.js'; 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 a959b75837..0912c5d00b 100644 --- a/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts +++ b/packages/client-generator/src/generators/__tests__/language-dogfooding.test.ts @@ -8,23 +8,26 @@ import { fileURLToPath } from 'node:url'; // toolkit) is a dogfooding violation, and also breaks the promise that a // python-only selection never loads the `typescript` package. const ALLOWED_SPECIFIERS = new Set([ - '../authoring/index.js', - '../emitters/python-runtime-sources.js', // pure embedded strings, generated at prepare time - '../emitters/go-runtime-sources.js', - '../emitters/php-runtime-sources.js', - '../intermediate-representation/model.js', // type-only IR shapes - './types.js', // the generator contract + '../../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.ts', 'go.ts', 'php.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/index.ts', 'go/index.ts', 'php/index.ts'])( + '%s dogfooding invariant', + (file) => { + it('imports only what the authoring skill offers to any custom generator', () => { + const source = readFileSync( + resolve(dirname(fileURLToPath(import.meta.url)), '..', file), + 'utf-8' + ); + const specifiers = [...source.matchAll(/from '([^']+)'/g)].map((match) => match[1]); + expect(specifiers.length).toBeGreaterThan(0); + const violations = specifiers.filter((specifier) => !ALLOWED_SPECIFIERS.has(specifier)); + expect(violations).toEqual([]); + }); + } +); diff --git a/packages/client-generator/src/generators/__tests__/php.test.ts b/packages/client-generator/src/generators/__tests__/php.test.ts index 626c413ee3..d253b33ac6 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, renderPhpModels } from '../php.js'; +import { phpGenerator, renderPhpModels } from '../php/index.js'; 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 26a06bdded..25e54c7b36 100644 --- a/packages/client-generator/src/generators/__tests__/python.test.ts +++ b/packages/client-generator/src/generators/__tests__/python.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 { pythonGenerator, renderPythonModels } from '../python.js'; +import { pythonGenerator, renderPythonModels } from '../python/index.js'; const hasPython = spawnSync('python3', ['--version']).status === 0; diff --git a/packages/client-generator/src/generators/go/AGENTS.md b/packages/client-generator/src/generators/go/AGENTS.md new file mode 100644 index 0000000000..5977468706 --- /dev/null +++ b/packages/client-generator/src/generators/go/AGENTS.md @@ -0,0 +1,42 @@ +# The `go` generator — its skill + +This file is the generator's DESIGN. It ships to users on `redocly eject-generator go` +(as `generators/go.AGENTS.md`) and governs our own changes: **to change the generator, +edit this skill first, then make the code match it** — a diff to `index.ts` that has no +covering sentence here is incomplete. + +## What it emits + +One self-contained `.go` (`package client`): structs with `json` tags, a `Client` +with one `(T, error)` method per operation taking a `context.Context`, and the embedded +runtime. Go ≥ 1.21, standard library only — zero dependencies. + +## Design decisions that must hold + +- **Models are structs**: required fields by value, optionals as pointers with + `,omitempty`; the `json` tag always carries the exact wire name. +- **Naming:** exported PascalCase via `identifierFor` + an `N` prefix for digit-leading + names (`3ds` → `N3ds` — an `_`-prefixed field is unexported and invisible to + `encoding/json`); `+1`/`-1` become `Plus1`/`Minus1`. +- **Enums** are typed consts (`type Status string` + `StatusInProgress Status = …`); + **discriminated unions** are `type X = any` plus a generated `UnmarshalX([]byte)` + dispatcher; **allOf** is flattened. +- **Errors:** `(T, error)` returns ARE the error mode — `errorMode` does not change the + output. Non-2xx → `*APIError`; timeouts → `*TimeoutError`. +- **Parity surface:** auth, retries with `Retry-After` + jittered backoff, per-attempt + `context.WithTimeout`, idempotency keys, middleware, pagination (`Pages`/`Items` + as `func(yield func(T, error) bool)` — `range`-over-func needs Go ≥ 1.23; 1.21 calls + them with a callback), SSE, multipart. +- The runtime is hand-written in `go-runtime/runtime.go` (gofmt-clean, `go vet`-clean) + and embedded at prepare time. +- Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Change `index.ts` (and `go-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` + (real `go build` + `go vet` bars), the e2e smoke (`tests/e2e/generate-client/go.test.ts`), + and `npm run harness`. diff --git a/packages/client-generator/src/generators/go.ts b/packages/client-generator/src/generators/go/index.ts similarity index 99% rename from packages/client-generator/src/generators/go.ts rename to packages/client-generator/src/generators/go/index.ts index c9193b4b3b..efa6b57c58 100644 --- a/packages/client-generator/src/generators/go.ts +++ b/packages/client-generator/src/generators/go/index.ts @@ -18,15 +18,15 @@ import { schemaAtPointer, unwrapNullable, type NeutralPaginationRule, -} from '../authoring/index.js'; -import { GO_RUNTIME_SOURCE } from '../emitters/go-runtime-sources.js'; +} from '../../authoring/index.js'; +import { GO_RUNTIME_SOURCE } from '../../emitters/go-runtime-sources.js'; import type { ApiModel, OperationModel, PropertyModel, SchemaModel, -} from '../intermediate-representation/model.js'; -import type { CodeSample, Generator, SampleContext } from './types.js'; +} from '../../intermediate-representation/model.js'; +import type { CodeSample, Generator, SampleContext } from '../types.js'; const GO = RESERVED_WORDS.go; diff --git a/packages/client-generator/src/generators/index.ts b/packages/client-generator/src/generators/index.ts index bfc2d0924f..5a344acbdd 100644 --- a/packages/client-generator/src/generators/index.ts +++ b/packages/client-generator/src/generators/index.ts @@ -1,10 +1,10 @@ import type { EmitOptions } from '../emitters/emit-options.js'; import { cliGenerator, cliSample } from './cli.js'; -import { goGenerator, goSample } from './go.js'; +import { goGenerator, goSample } from './go/index.js'; import { BUILTIN_META, validateSelection, type BuiltinMeta } from './meta.js'; import { mockGenerator } from './mock.js'; -import { phpGenerator, phpSample } from './php.js'; -import { pythonGenerator, pythonSample } from './python.js'; +import { phpGenerator, phpSample } from './php/index.js'; +import { pythonGenerator, pythonSample } from './python/index.js'; import { sdkGenerator, sdkSample } from './sdk.js'; import { swrGenerator } from './swr.js'; import { tanstackQueryGenerator } from './tanstack-query.js'; diff --git a/packages/client-generator/src/generators/meta.ts b/packages/client-generator/src/generators/meta.ts index 830aca9306..7f6df6093b 100644 --- a/packages/client-generator/src/generators/meta.ts +++ b/packages/client-generator/src/generators/meta.ts @@ -62,15 +62,16 @@ export const BUILTIN_META: Record = { // so a python-only selection never loads the `typescript` package. python: { load: () => - import('./python.js').then((m) => ({ run: m.pythonGenerator, sample: m.pythonSample })), + import('./python/index.js').then((m) => ({ run: m.pythonGenerator, sample: m.pythonSample })), }, // go emits a standalone full Go SDK (stdlib-only) — no TypeScript involved. go: { - load: () => import('./go.js').then((m) => ({ run: m.goGenerator, sample: m.goSample })), + load: () => import('./go/index.js').then((m) => ({ run: m.goGenerator, sample: m.goSample })), }, // php emits a standalone full PHP SDK (curl extension) — no TypeScript involved. php: { - load: () => import('./php.js').then((m) => ({ run: m.phpGenerator, sample: m.phpSample })), + load: () => + import('./php/index.js').then((m) => ({ run: m.phpGenerator, sample: m.phpSample })), }, }; diff --git a/packages/client-generator/src/generators/php/AGENTS.md b/packages/client-generator/src/generators/php/AGENTS.md new file mode 100644 index 0000000000..0f61b1f877 --- /dev/null +++ b/packages/client-generator/src/generators/php/AGENTS.md @@ -0,0 +1,47 @@ +# The `php` generator — its skill + +This file is the generator's DESIGN. It ships to users on `redocly eject-generator php` +(as `generators/php.AGENTS.md`) and governs our own changes: **to change the generator, +edit this skill first, then make the code match it** — a diff to `index.ts` that has no +covering sentence here is incomplete. + +## What it emits + +One self-contained `.php`: promoted-constructor model classes, a `Client` with one +typed method per operation, and the embedded runtime. PHP ≥ 8.1, HTTP over the curl +extension — zero Composer dependencies. The namespace derives from the API title +(`identifierFor(title, pascal)` — e.g. `RedoclyCafe`). + +## Design decisions that must hold + +- **Models are `final class`es** with constructor property promotion, required parameters + first, optionals nullable `= null`. Hydration is compile-time generated per class: + `fromArray(array $data): self` and `toArray(): array` (wire names inline; nulls + skipped on serialize) — no reflection. `omit` schemas hydrate/serialize through their + base class. +- **Naming:** classes PascalCase, properties/methods camelCase via + `identifierFor(..., RESERVED_WORDS.php)`; reserved words get a trailing underscore. +- **Enums** are native backed enums (string/int); other scalars stay aliases. + **Discriminated unions** are `match`-based `unmarshalX(array $data)` dispatchers; + **allOf** is flattened. +- **Errors:** exceptions ARE the error mode (`ApiError`/`TimeoutError` extend + `\RuntimeException`); `errorMode` does not change the output. +- **Method arguments:** required path params positional, JSON body next, optional query + params as nullable NAMED arguments, then `?array $headers`, and `?string +$idempotencyKey` on mutating methods. +- **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 `php-runtime/runtime.php` (`php -l`-clean) and embedded + at prepare time. `curl_close` is never called (deprecated since PHP 8.5, no-op since 8.0). +- Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Change `index.ts` (and `php-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` + (real `php -l` + `require` bars), the e2e smoke (`tests/e2e/generate-client/php.test.ts`), + and `npm run harness`. diff --git a/packages/client-generator/src/generators/php.ts b/packages/client-generator/src/generators/php/index.ts similarity index 99% rename from packages/client-generator/src/generators/php.ts rename to packages/client-generator/src/generators/php/index.ts index 9fd96be2a8..cec8efa1f6 100644 --- a/packages/client-generator/src/generators/php.ts +++ b/packages/client-generator/src/generators/php/index.ts @@ -18,15 +18,15 @@ import { schemaAtPointer, unwrapNullable, type NeutralPaginationRule, -} from '../authoring/index.js'; -import { PHP_RUNTIME_SOURCE } from '../emitters/php-runtime-sources.js'; +} from '../../authoring/index.js'; +import { PHP_RUNTIME_SOURCE } from '../../emitters/php-runtime-sources.js'; import type { ApiModel, OperationModel, PropertyModel, SchemaModel, -} from '../intermediate-representation/model.js'; -import type { CodeSample, Generator, SampleContext } from './types.js'; +} from '../../intermediate-representation/model.js'; +import type { CodeSample, Generator, SampleContext } from '../types.js'; const PHP = RESERVED_WORDS.php; diff --git a/packages/client-generator/src/generators/python/AGENTS.md b/packages/client-generator/src/generators/python/AGENTS.md new file mode 100644 index 0000000000..8b35189e2d --- /dev/null +++ b/packages/client-generator/src/generators/python/AGENTS.md @@ -0,0 +1,42 @@ +# The `python` generator — its skill + +This file is the generator's DESIGN. It ships to users on `redocly eject-generator python` +(as `generators/python.AGENTS.md`) and governs our own changes: **to change the generator, +edit this skill first, then make the code match it** — a diff to `index.ts` that has no +covering sentence here is incomplete. + +## What it emits + +One self-contained `.py`: typed dataclass models, a sync `Client` and an async +`AsyncClient`, and the embedded runtime. Python ≥ 3.9; the only dependency is +[httpx](https://www.python-httpx.org/) (`pip install httpx`). + +## Design decisions that must hold + +- **Models are dataclasses**, required fields first (a dataclass constraint), optionals + `Optional[T] = None`. Wire names live in a `_field_map: ClassVar[Dict[str, str]]`; + decode/encode is reflective (`_decode.py`, `get_type_hints`) — no per-model codecs. +- **Naming:** fields/methods snake*case via `identifierFor(..., RESERVED_WORDS.python)`; + reserved words get a trailing underscore (`class*`); `+1`/`-1`become`plus_1`/`minus_1`. +- **Enums** are `class X(str, Enum)` with SCREAMING members; **discriminated unions** are + `Union[...]` aliases plus a decode dispatch on the discriminator property; **allOf** is + flattened via `flattenAllOf`. +- **Errors:** `errorMode` maps to raising `ApiError` (default) or returning a `Result` + dataclass — the only generator with both modes outside TypeScript. +- **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 `python-runtime/*.py` and embedded as strings at prepare + time — generator code never builds runtime logic from templates. +- Authored ONLY with the neutral toolkit (`Printer`, naming, schema, pagination helpers) — + the dogfooding guard fails otherwise. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Change `index.ts` (and `python-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` + (real `py_compile` bars), the e2e smoke (`tests/e2e/generate-client/python.test.ts`), + and `npm run harness` (Rebilly + GitHub import bars). diff --git a/packages/client-generator/src/generators/python.ts b/packages/client-generator/src/generators/python/index.ts similarity index 98% rename from packages/client-generator/src/generators/python.ts rename to packages/client-generator/src/generators/python/index.ts index 30f02f3906..216cc6bd9d 100644 --- a/packages/client-generator/src/generators/python.ts +++ b/packages/client-generator/src/generators/python/index.ts @@ -15,15 +15,15 @@ import { isNullable, RESERVED_WORDS, unwrapNullable, -} from '../authoring/index.js'; -import { PYTHON_RUNTIME_SOURCES } from '../emitters/python-runtime-sources.js'; +} from '../../authoring/index.js'; +import { PYTHON_RUNTIME_SOURCES } from '../../emitters/python-runtime-sources.js'; import type { ApiModel, OperationModel, PropertyModel, SchemaModel, -} from '../intermediate-representation/model.js'; -import type { CodeSample, Generator, SampleContext } from './types.js'; +} from '../../intermediate-representation/model.js'; +import type { CodeSample, Generator, SampleContext } from '../types.js'; const PY = RESERVED_WORDS.python; diff --git a/tests/e2e/generate-client/eject.test.ts b/tests/e2e/generate-client/eject.test.ts index 8b92c35045..65ce996d5d 100644 --- a/tests/e2e/generate-client/eject.test.ts +++ b/tests/e2e/generate-client/eject.test.ts @@ -53,6 +53,11 @@ describe('eject-generator / scaffold-generator (end-to-end)', () => { expect(readFileSync(join(project, 'generators/AGENTS.md'), 'utf-8')).toContain( 'redocly-generators:begin' ); + // The generator's OWN design skill ships alongside — the file an agent reads + // before editing the ejected generator. + expect(readFileSync(join(project, 'generators/php.AGENTS.md'), 'utf-8')).toContain( + 'edit this skill first' + ); expect(run(project, ['eject-generator', 'php']).status).not.toBe(0); expect(run(project, ['eject-generator', 'php', '--force']).status).toBe(0); }, 60_000); diff --git a/tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md b/tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md new file mode 100644 index 0000000000..0f61b1f877 --- /dev/null +++ b/tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md @@ -0,0 +1,47 @@ +# The `php` generator — its skill + +This file is the generator's DESIGN. It ships to users on `redocly eject-generator php` +(as `generators/php.AGENTS.md`) and governs our own changes: **to change the generator, +edit this skill first, then make the code match it** — a diff to `index.ts` that has no +covering sentence here is incomplete. + +## What it emits + +One self-contained `.php`: promoted-constructor model classes, a `Client` with one +typed method per operation, and the embedded runtime. PHP ≥ 8.1, HTTP over the curl +extension — zero Composer dependencies. The namespace derives from the API title +(`identifierFor(title, pascal)` — e.g. `RedoclyCafe`). + +## Design decisions that must hold + +- **Models are `final class`es** with constructor property promotion, required parameters + first, optionals nullable `= null`. Hydration is compile-time generated per class: + `fromArray(array $data): self` and `toArray(): array` (wire names inline; nulls + skipped on serialize) — no reflection. `omit` schemas hydrate/serialize through their + base class. +- **Naming:** classes PascalCase, properties/methods camelCase via + `identifierFor(..., RESERVED_WORDS.php)`; reserved words get a trailing underscore. +- **Enums** are native backed enums (string/int); other scalars stay aliases. + **Discriminated unions** are `match`-based `unmarshalX(array $data)` dispatchers; + **allOf** is flattened. +- **Errors:** exceptions ARE the error mode (`ApiError`/`TimeoutError` extend + `\RuntimeException`); `errorMode` does not change the output. +- **Method arguments:** required path params positional, JSON body next, optional query + params as nullable NAMED arguments, then `?array $headers`, and `?string +$idempotencyKey` on mutating methods. +- **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 `php-runtime/runtime.php` (`php -l`-clean) and embedded + at prepare time. `curl_close` is never called (deprecated since PHP 8.5, no-op since 8.0). +- Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Change `index.ts` (and `php-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` + (real `php -l` + `require` bars), the e2e smoke (`tests/e2e/generate-client/php.test.ts`), + and `npm run harness`. From 8335506cdb9b4d948baceb1d0668d88df9742c8f Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 15:57:52 +0300 Subject: [PATCH 060/211] =?UTF-8?q?docs(client-generator):=20java=20genera?= =?UTF-8?q?tor=20skill=20draft=20=E2=80=94=20design=20for=20review,=20no?= =?UTF-8?q?=20code=20yet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/generators/java/AGENTS.md | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 packages/client-generator/src/generators/java/AGENTS.md diff --git a/packages/client-generator/src/generators/java/AGENTS.md b/packages/client-generator/src/generators/java/AGENTS.md new file mode 100644 index 0000000000..b899b0cf02 --- /dev/null +++ b/packages/client-generator/src/generators/java/AGENTS.md @@ -0,0 +1,72 @@ +# The `java` generator — its skill (DRAFT, design under review — no code exists yet) + +This file is the generator's DESIGN, written before any implementation (skill-first). +Once approved it ships to users on `redocly eject-generator java` (as +`generators/java.AGENTS.md`) and governs all changes: **edit this skill first, then +make the code match it.** + +## What it emits + +A Java SDK from an OpenAPI description: typed models, a `Client` with one method per +operation, and the embedded runtime. **Java ≥ 17** (records, sealed interfaces, +switch patterns), HTTP over `java.net.http.HttpClient` — part of the JDK since 11. + +## ⚠ Open decisions for review (resolve before implementing) + +1. **JSON.** Java has NO stdlib JSON. Options: + - **(Recommended) Hand-written minimal JSON in the embedded runtime** — `Json.parse` + into a `Map/List/String/Double/Boolean/null` graph plus `Json.write`; ~300 lines, + verified like the other hand-written runtimes. Keeps the zero-dependency story + uniform with go/php. Limits (recorded honestly): no streaming parse; integral + numbers surface as `long`, fractions as `double`. + - Depend on Jackson — idiomatic and battle-tested, but the first generated SDK with a + runtime dependency, breaking the story users already know from go/php. +2. **File layout.** Java allows one public top-level class per file, so a single-file SDK + is impossible in the flat style the other languages use. Options: + - **(Recommended) Multi-file**: the generator emits a directory — + `/Client.java`, one file per model, `Runtime` support classes — under a + `package` derived from the API title (`com.example` configurable later). First + generator to use directory output; the pipeline already supports multiple files. + - Single file with everything nested inside one public class (`Api.Order`, + `Api.Client`) — keeps single-file symmetry but reads unidiomatic to Java teams. +3. **Errors.** Unchecked `ApiException extends RuntimeException` (recommended — checked + exceptions on every call poison lambdas/streams), carrying `status`, `url`, decoded + `body`; `TimeoutException` variant for exhausted attempts. + +## Design decisions (settled by precedent with the other languages) + +- **Models are records**: required components first; optionals as nullable boxed fields + (`Integer`, not `int`). Hydration is compile-time generated per record — + `static Order fromJson(Object json)` and `Object toJson()` over the runtime's JSON + graph, mirroring PHP's `fromArray`/`toArray` (no reflection). Wire names inline. +- **Naming:** classes PascalCase, fields/methods camelCase via + `identifierFor(..., RESERVED_WORDS.java)` (the `java` reserved set is new toolkit work); + `+1`/`-1` → `plus1`/`minus1`; digit-leading names get a letter prefix. +- **Enums** are Java enums with a `wire()` accessor and a `fromWire(String)` factory + (values like `in-progress` are not valid Java identifiers, so members are + SCREAMING_SNAKE with the wire literal attached). +- **Discriminated unions** are `sealed interface X permits A, B` with a generated + `static X parseX(Object json)` dispatcher on the discriminator property. Members + gain `implements X`. Undiscriminated unions surface as `Object`. +- **allOf** is flattened via `flattenAllOf`; `omit` uses the base record (readOnly + fields simply omitted from requests). +- **Client:** `new Client(Config config)`; per-op methods + `OrderPage listOrders(ListOrdersParams params)` throwing `ApiException`; params + objects are records with a builder (Java has no named arguments). +- **Parity surface** (same as python/go/php): auth (bearer/basic/apiKey), retries with + `Retry-After` + jittered backoff, per-attempt timeouts, idempotency keys, middleware + (`UnaryOperator`-style interceptors), pagination (`Iterable listOrdersPages()` + / `Iterable listOrdersItems()`), SSE (`Iterator` with + `Last-Event-ID` reconnect), multipart (hand-built body), `X-Redocly-Client` header. +- The runtime is hand-written in `java-runtime/` and embedded at prepare time; verified + with `javac` (and the smoke against the shared mock server). Harness gains a `javaBar` + (`javac` on Rebilly + GitHub output). GitHub CI runners ship a JDK. +- Authored ONLY with the neutral toolkit — the dogfooding guard extends to `java/index.ts`. + +## The modify loop (once implemented) + +1. Edit this skill: state the new behavior or decision. +2. Change `index.ts` (and `java-runtime/` for runtime behavior, then + `npm run prepare -w @redocly/client-generator`). +3. Verify: `npm run compile`, the generator unit suite (real `javac` bars), the e2e + smoke, and `npm run harness`. From 31f3a8076d4ce813bfbdcb9e9463d940ecf0fef3 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 16:08:17 +0300 Subject: [PATCH 061/211] fix(client-generator): pin eject skill assets to their sources, correct the python union-decode claim, clean-rebuild guard for publishes --- packages/client-generator/package.json | 3 ++- .../src/generators/__tests__/generator-skills.test.ts | 7 +++++++ .../client-generator/src/generators/python/AGENTS.md | 9 ++++++--- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/client-generator/package.json b/packages/client-generator/package.json index 0cd64f3406..9b189ca487 100644 --- a/packages/client-generator/package.json +++ b/packages/client-generator/package.json @@ -34,7 +34,8 @@ "scripts": { "examples:regen": "node scripts/regenerate-examples.mjs", "prepare": "node scripts/generate-runtime-sources.mjs && node scripts/generate-eject-assets.mjs", - "typecheck:examples": "node scripts/typecheck-examples.mjs" + "typecheck:examples": "node scripts/typecheck-examples.mjs", + "prepublishOnly": "rm -rf lib *.tsbuildinfo && tsc -b tsconfig.build.json" }, "license": "MIT", "repository": { 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 157633a2ff..289a637f5b 100644 --- a/packages/client-generator/src/generators/__tests__/generator-skills.test.ts +++ b/packages/client-generator/src/generators/__tests__/generator-skills.test.ts @@ -21,4 +21,11 @@ describe.each(['python', 'go', 'php'])('%s generator skill', (name) => { expect(skill).toContain('edit this skill first'); expect(skill).toContain('npm run harness'); }); + + it('is what eject ships — the prepared asset matches the source byte-for-byte', () => { + // `prepare` copies the skill into eject-assets; commit-time formatting of the + // source AFTER a prepare run would silently ship a stale copy without this pin. + const asset = join(generatorsDir, '../../eject-assets/generators', `${name}.AGENTS.md`); + expect(readFileSync(asset, 'utf-8')).toBe(readFileSync(skillPath, 'utf-8')); + }); }); diff --git a/packages/client-generator/src/generators/python/AGENTS.md b/packages/client-generator/src/generators/python/AGENTS.md index 8b35189e2d..99db230d10 100644 --- a/packages/client-generator/src/generators/python/AGENTS.md +++ b/packages/client-generator/src/generators/python/AGENTS.md @@ -18,9 +18,12 @@ One self-contained `.py`: typed dataclass models, a sync `Client` and an a decode/encode is reflective (`_decode.py`, `get_type_hints`) — no per-model codecs. - **Naming:** fields/methods snake*case via `identifierFor(..., RESERVED_WORDS.python)`; reserved words get a trailing underscore (`class*`); `+1`/`-1`become`plus_1`/`minus_1`. -- **Enums** are `class X(str, Enum)` with SCREAMING members; **discriminated unions** are - `Union[...]` aliases plus a decode dispatch on the discriminator property; **allOf** is - flattened via `flattenAllOf`. +- **Enums** are `class X(str, Enum)` with SCREAMING members; **unions** are `Union[...]` + aliases, decoded by trying each member in order (the first that hydrates wins — see + `_decode.py`); a discriminator, when present, is emitted as a table COMMENT on the + alias, not as runtime dispatch. (Discriminator-driven dispatch is a known improvement + candidate: update this paragraph first, then `_decode.py`.) **allOf** is flattened via + `flattenAllOf`. - **Errors:** `errorMode` maps to raising `ApiError` (default) or returning a `Result` dataclass — the only generator with both modes outside TypeScript. - **Parity surface:** auth (bearer/basic/apiKey), retries with `Retry-After` + jittered From 6e067981f72777726dbfbaae5e1a36b8d4e3e3b4 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 16:28:56 +0300 Subject: [PATCH 062/211] feat(cli)!: rename scaffold-generator to architect-generator --- .changeset/eject-architect-generators.md | 6 ++++++ .changeset/eject-scaffold-generators.md | 6 ------ .changeset/eject-telemetry.md | 2 +- ...ffold-generator.md => architect-generator.md} | 10 +++++----- docs/@v2/commands/index.md | 2 +- docs/@v2/guides/customize-client-generation.md | 4 ++-- docs/@v2/usage-data.md | 4 ++-- docs/@v2/v2.sidebars.yaml | 4 ++-- .../__tests__/commands/eject-generator.test.ts | 10 +++++----- ...ffold-generator.ts => architect-generator.ts} | 16 ++++++++-------- packages/cli/src/index.ts | 14 +++++++------- packages/cli/src/types.ts | 4 ++-- .../cli/src/utils/generate-client-telemetry.ts | 6 +++--- packages/cli/src/utils/telemetry.ts | 2 +- tests/e2e/generate-client/eject.test.ts | 14 +++++++------- tests/e2e/generate-client/examples.test.ts | 6 +++--- tests/e2e/generate-client/examples/README.md | 2 +- .../.gitignore | 0 .../examples/architected-generator/README.md | 12 ++++++++++++ .../generators/AGENTS.md | 0 .../generators/ops-summary.mjs | 2 +- .../package.json | 4 ++-- .../redocly.yaml | 2 +- .../examples/scaffolded-generator/README.md | 12 ------------ 24 files changed, 72 insertions(+), 72 deletions(-) create mode 100644 .changeset/eject-architect-generators.md delete mode 100644 .changeset/eject-scaffold-generators.md rename docs/@v2/commands/{scaffold-generator.md => architect-generator.md} (72%) rename packages/cli/src/commands/{scaffold-generator.ts => architect-generator.ts} (89%) rename tests/e2e/generate-client/examples/{scaffolded-generator => architected-generator}/.gitignore (100%) create mode 100644 tests/e2e/generate-client/examples/architected-generator/README.md rename tests/e2e/generate-client/examples/{scaffolded-generator => architected-generator}/generators/AGENTS.md (100%) rename tests/e2e/generate-client/examples/{scaffolded-generator => architected-generator}/generators/ops-summary.mjs (92%) rename tests/e2e/generate-client/examples/{scaffolded-generator => architected-generator}/package.json (66%) rename tests/e2e/generate-client/examples/{scaffolded-generator => architected-generator}/redocly.yaml (90%) delete mode 100644 tests/e2e/generate-client/examples/scaffolded-generator/README.md diff --git a/.changeset/eject-architect-generators.md b/.changeset/eject-architect-generators.md new file mode 100644 index 0000000000..152b427b1c --- /dev/null +++ b/.changeset/eject-architect-generators.md @@ -0,0 +1,6 @@ +--- +'@redocly/client-generator': minor +'@redocly/cli': minor +--- + +Added `redocly eject-generator` and `redocly architect-generator` — vendor a built-in language generator (`python`, `go`, `php`) into your repo as an editable file (with a pristine snapshot, three-way `--update` merges, and byte-identical output when unmodified), or architect a custom generator skeleton; both drop the `AGENTS.md` generator-authoring guide for coding agents. A path-loaded generator may now take over a built-in name, and the new `@redocly/client-generator/runtime-sources` entry serves the embedded-runtime sources to ejected generators. diff --git a/.changeset/eject-scaffold-generators.md b/.changeset/eject-scaffold-generators.md deleted file mode 100644 index 8c0c9d6ba7..0000000000 --- a/.changeset/eject-scaffold-generators.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@redocly/client-generator': minor -'@redocly/cli': minor ---- - -Added `redocly eject-generator` and `redocly scaffold-generator` — vendor a built-in language generator (`python`, `go`, `php`) into your repo as an editable file (with a pristine snapshot, three-way `--update` merges, and byte-identical output when unmodified), or scaffold a custom generator skeleton; both drop the `AGENTS.md` generator-authoring guide for coding agents. A path-loaded generator may now take over a built-in name, and the new `@redocly/client-generator/runtime-sources` entry serves the embedded-runtime sources to ejected generators. diff --git a/.changeset/eject-telemetry.md b/.changeset/eject-telemetry.md index 727560a28c..18e42b918b 100644 --- a/.changeset/eject-telemetry.md +++ b/.changeset/eject-telemetry.md @@ -3,4 +3,4 @@ '@redocly/cli': patch --- -Added coarse usage telemetry for the eject workflow (respecting the `REDOCLY_TELEMETRY` opt-out, documented on the usage-data page): `eject-generator`/`scaffold-generator` report the action and outcome category (such as clean or conflicted `--update` merges), `generate-client` reports the built-in origin and ejected-from version of path generators that carry the eject provenance header, and a generator that throws during a run is now reported as the `generator-run` error category with the failing generator named in the CLI error message. File contents, paths, and user-chosen names are never transmitted. +Added coarse usage telemetry for the eject workflow (respecting the `REDOCLY_TELEMETRY` opt-out, documented on the usage-data page): `eject-generator`/`architect-generator` report the action and outcome category (such as clean or conflicted `--update` merges), `generate-client` reports the built-in origin and ejected-from version of path generators that carry the eject provenance header, and a generator that throws during a run is now reported as the `generator-run` error category with the failing generator named in the CLI error message. File contents, paths, and user-chosen names are never transmitted. diff --git a/docs/@v2/commands/scaffold-generator.md b/docs/@v2/commands/architect-generator.md similarity index 72% rename from docs/@v2/commands/scaffold-generator.md rename to docs/@v2/commands/architect-generator.md index cbfb99a3dd..93ac48fad6 100644 --- a/docs/@v2/commands/scaffold-generator.md +++ b/docs/@v2/commands/architect-generator.md @@ -1,15 +1,15 @@ -# `scaffold-generator` +# `architect-generator` ## Introduction -The `scaffold-generator` command creates a custom client-generator skeleton — for emitting an artifact no built-in generator covers (a route map, a facade, an SDK in another language). +The `architect-generator` command creates a custom client-generator skeleton — for emitting an artifact no built-in generator covers (a route map, a facade, an SDK in another language). It also drops `AGENTS.md`, the authoring guide that teaches your coding agent the generator contract, the API model shape, and the language-neutral helpers. ## Usage ```bash -redocly scaffold-generator route-map -redocly scaffold-generator my-sdk --dir ./generators +redocly architect-generator route-map +redocly architect-generator my-sdk --dir ./generators ``` ## Options @@ -17,7 +17,7 @@ redocly scaffold-generator my-sdk --dir ./generators | Option | Type | Description | | --------- | ------ | -------------------------------------------------------------------- | | generator | string | Name for the new generator (kebab-case; built-in names are refused). | -| `--dir` | string | Directory to scaffold into. Default `./generators`. | +| `--dir` | string | Directory to architect into. Default `./generators`. | ## How it works diff --git a/docs/@v2/commands/index.md b/docs/@v2/commands/index.md index 4b60a30131..5719210ea6 100644 --- a/docs/@v2/commands/index.md +++ b/docs/@v2/commands/index.md @@ -16,7 +16,7 @@ API management commands: - [`bundle`](bundle.md) Bundle API description. - [`generate-client`](generate-client.md) Generate a typed TypeScript client from an OpenAPI description [experimental feature]. - [`eject-generator`](eject-generator.md) Vendor a built-in client generator into your repo as an editable file [experimental feature]. -- [`scaffold-generator`](scaffold-generator.md) Create a custom client-generator skeleton plus the authoring guide [experimental feature]. +- [`architect-generator`](architect-generator.md) Create a custom client-generator skeleton plus the authoring guide [experimental feature]. - [`join`](join.md) Join API descriptions [experimental feature]. - [`score`](score.md) Score an API for integration simplicity and AI agent readiness. - [`split`](split.md) Split API description into a multi-file structure. diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index 3451af257d..696f12d912 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -61,13 +61,13 @@ Express un-bypassable behavior as middleware, not a custom `fetch`. A setup file may import **only** from `@redocly/client-generator`. See the [`baked-setup` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/baked-setup). -## Eject and scaffold +## Eject and architect The fastest paths to a customized generator are the two commands: - [`redocly eject-generator `](../commands/eject-generator.md) vendors a built-in language generator (`python`, `go`, `php`) into `./generators/` as an editable file, with a pristine snapshot for [three-way updates](../commands/eject-generator.md#how-it-works) and the `AGENTS.md` authoring guide for your coding agent. An ejected-unmodified generator produces byte-identical output, and the path entry takes over the built-in name — regeneration survives every customization. -- [`redocly scaffold-generator `](../commands/scaffold-generator.md) creates a runnable skeleton for an artifact no built-in covers. +- [`redocly architect-generator `](../commands/architect-generator.md) creates a runnable skeleton for an artifact no built-in covers. Both drop `AGENTS.md` next to the generator: your agent reads it to learn the model shape, the helper library, and the verify loop (edit the generator → `redocly generate-client` → review the client diff — generated files are never hand-edited). diff --git a/docs/@v2/usage-data.md b/docs/@v2/usage-data.md index 49a1319419..69e0a08d46 100644 --- a/docs/@v2/usage-data.md +++ b/docs/@v2/usage-data.md @@ -23,8 +23,8 @@ When a command is run, the following data is collected: - Arazzo x-security authentication types - for `generate-client`: which built-in generators ran, the count of custom generators, which of the package's own exported helper names a custom generator imports, and a coarse error category on failure. When a path-loaded generator carries the `eject-generator` provenance header, its built-in origin and the version it was ejected from are included (for example `php@0.2.0`) — the file's contents, path, and any user-chosen names are never transmitted. -- for `eject-generator` and `scaffold-generator`: the action (`eject`, `update`, `guidance`, `scaffold`), the built-in generator name for eject actions, and a coarse outcome category (such as `success`, `conflicts` with the conflict count, `already-exists`, or `merge-tool-missing`). - A scaffolded generator's name is user-chosen and is never transmitted. +- for `eject-generator` and `architect-generator`: the action (`eject`, `update`, `guidance`, `architect`), the built-in generator name for eject actions, and a coarse outcome category (such as `success`, `conflicts` with the conflict count, `already-exists`, or `merge-tool-missing`). + A architected generator's name is user-chosen and is never transmitted. Custom generator file contents, paths, and names are never collected. - platform (Linux, macOS, Windows) - anonymous ID (a randomly generated identifier that doesn't contain personal information) diff --git a/docs/@v2/v2.sidebars.yaml b/docs/@v2/v2.sidebars.yaml index 4d747665d5..398ec757b3 100644 --- a/docs/@v2/v2.sidebars.yaml +++ b/docs/@v2/v2.sidebars.yaml @@ -44,8 +44,8 @@ page: commands/push-status.md - label: respect page: commands/respect.md - - label: scaffold-generator - page: commands/scaffold-generator.md + - label: architect-generator + page: commands/architect-generator.md - label: score page: commands/score.md - label: scorecard-classic diff --git a/packages/cli/src/__tests__/commands/eject-generator.test.ts b/packages/cli/src/__tests__/commands/eject-generator.test.ts index 51c1a6e6e4..2e89cea6fd 100644 --- a/packages/cli/src/__tests__/commands/eject-generator.test.ts +++ b/packages/cli/src/__tests__/commands/eject-generator.test.ts @@ -1,5 +1,5 @@ +import { handleArchitectGenerator } from '../../commands/architect-generator.js'; import { handleEjectGenerator } from '../../commands/eject-generator.js'; -import { handleScaffoldGenerator } from '../../commands/scaffold-generator.js'; import { ejectGeneratorTelemetry } from '../../utils/generate-client-telemetry.js'; import type { CommandArgs } from '../../wrapper.js'; @@ -14,7 +14,7 @@ function reset() { } } -describe('eject/scaffold telemetry (coarse categories only)', () => { +describe('eject/architect telemetry (coarse categories only)', () => { beforeEach(reset); it('sdk guidance records the allowlisted name and a guidance action', async () => { @@ -37,12 +37,12 @@ describe('eject/scaffold telemetry (coarse categories only)', () => { expect(ejectGeneratorTelemetry.eject_generator_name).toBeUndefined(); }); - it('scaffolding a built-in name records the refusal, not the name', async () => { + it('architecting a built-in name records the refusal, not the name', async () => { await expect( - handleScaffoldGenerator({ ...baseArgs, argv: { generator: 'php' } } as CommandArgs) + handleArchitectGenerator({ ...baseArgs, argv: { generator: 'php' } } as CommandArgs) ).rejects.toThrow(/built-in generator/); expect(ejectGeneratorTelemetry).toEqual({ - eject_generator_action: 'scaffold', + eject_generator_action: 'architect', eject_generator_outcome: 'builtin-name', }); }); diff --git a/packages/cli/src/commands/scaffold-generator.ts b/packages/cli/src/commands/architect-generator.ts similarity index 89% rename from packages/cli/src/commands/scaffold-generator.ts rename to packages/cli/src/commands/architect-generator.ts index 548a1a8e34..8abed67dd6 100644 --- a/packages/cli/src/commands/scaffold-generator.ts +++ b/packages/cli/src/commands/architect-generator.ts @@ -6,7 +6,7 @@ import { ejectGeneratorTelemetry } from '../utils/generate-client-telemetry.js'; import { type CommandArgs } from '../wrapper.js'; import { ejectAssetsDir } from './eject-generator.js'; -export type ScaffoldGeneratorCommandArgv = { +export type ArchitectGeneratorCommandArgv = { generator?: string; config?: string; dir?: string; @@ -29,7 +29,7 @@ const BUILTIN_NAMES = new Set([ ]); function skeleton(name: string): string { - return `// A custom generator scaffolded by \`redocly scaffold-generator\`. + return `// A custom generator architected by \`redocly architect-generator\`. // It runs from the \`generators\` list in redocly.yaml and emits files next to the // configured client output. The authoring guide for your agent is in ./AGENTS.md; // the deep reference is the "Customize client generation" guide in the Redocly docs. @@ -58,17 +58,17 @@ export default { `; } -export const handleScaffoldGenerator = async ({ +export const handleArchitectGenerator = async ({ argv, -}: CommandArgs) => { +}: CommandArgs) => { const name = argv.generator ?? ''; - // Coarse usage telemetry: action + outcome category only — a scaffolded generator's + // Coarse usage telemetry: action + outcome category only — a architected generator's // name is user-chosen and never transmitted. - ejectGeneratorTelemetry.eject_generator_action = 'scaffold'; + ejectGeneratorTelemetry.eject_generator_action = 'architect'; if (!/^[a-z][a-z0-9-]*$/.test(name)) { ejectGeneratorTelemetry.eject_generator_outcome = 'invalid-name'; throw new HandledError( - `\n❌ Generator name must be kebab-case (got "${name}"). Example: redocly scaffold-generator route-map\n` + `\n❌ Generator name must be kebab-case (got "${name}"). Example: redocly architect-generator route-map\n` ); } if (BUILTIN_NAMES.has(name)) { @@ -100,7 +100,7 @@ export const handleScaffoldGenerator = async ({ ejectGeneratorTelemetry.eject_generator_outcome = 'success'; const configPath = `./${relative(process.cwd(), target).split('\\').join('/')}`; logger.info( - `Scaffolded ${relative(process.cwd(), target)}.\n` + + `Architected ${relative(process.cwd(), target)}.\n` + `Add it to your config and run \`redocly generate-client\`:\n\n` + ` client:\n generators:\n - sdk\n - ${configPath}\n` ); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 075fd4d682..79c55b1447 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -12,6 +12,10 @@ import * as path from 'node:path'; import yargs, { type Arguments } from 'yargs'; import { hideBin } from 'yargs/helpers'; +import { + handleArchitectGenerator, + type ArchitectGeneratorCommandArgv, +} from './commands/architect-generator.js'; import { handleLogin, handleLogout } from './commands/auth.js'; import type { BuildDocsArgv } from './commands/build-docs/types.js'; import { handleBundle } from './commands/bundle.js'; @@ -39,10 +43,6 @@ import { previewProject } from './commands/preview-project/index.js'; import { type ProxyArgv } from './commands/proxy/index.js'; import { handleRespect, type RespectArgv } from './commands/respect/index.js'; import { validateMtlsCommandOption } from './commands/respect/mtls/validate-mtls-command-option.js'; -import { - handleScaffoldGenerator, - type ScaffoldGeneratorCommandArgv, -} from './commands/scaffold-generator.js'; import { handleScore } from './commands/score/index.js'; import { handleScorecardClassic } from './commands/scorecard-classic/index.js'; import type { @@ -996,7 +996,7 @@ yargs(hideBin(process.argv)) } ) .command( - 'scaffold-generator [generator]', + 'architect-generator [generator]', 'Create a custom client-generator skeleton plus the authoring guide (AGENTS.md) [experimental].', (yargs) => yargs @@ -1006,14 +1006,14 @@ yargs(hideBin(process.argv)) }) .options({ dir: { - describe: 'Directory to scaffold into.', + describe: 'Directory to architect into.', type: 'string', default: './generators', requiresArg: true, }, }), async (argv) => { - commandWrapper(handleScaffoldGenerator)(argv as Arguments); + commandWrapper(handleArchitectGenerator)(argv as Arguments); } ) .command( diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index a5ddeef5bb..88aa725b7f 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -1,5 +1,6 @@ import type { RuleSeverity } from '@redocly/openapi-core'; +import type { ArchitectGeneratorCommandArgv } from './commands/architect-generator.js'; import type { LoginArgv, LogoutArgv } from './commands/auth.js'; import type { BuildDocsArgv } from './commands/build-docs/types.js'; import type { BundleArgv } from './commands/bundle.js'; @@ -12,7 +13,6 @@ import type { LintArgv } from './commands/lint.js'; import type { PreviewProjectArgv } from './commands/preview-project/types.js'; import type { ProxyArgv } from './commands/proxy/index.js'; import type { RespectArgv } from './commands/respect/index.js'; -import type { ScaffoldGeneratorCommandArgv } from './commands/scaffold-generator.js'; import type { SplitArgv } from './commands/split/types.js'; import type { StatsArgv } from './commands/stats/index.js'; import type { TranslationsArgv } from './commands/translations.js'; @@ -50,7 +50,7 @@ export type CommandArgv = | ProxyArgv | GenerateArazzoCommandArgv | EjectGeneratorCommandArgv - | ScaffoldGeneratorCommandArgv; + | ArchitectGeneratorCommandArgv; export type VerifyConfigOptions = { config?: string; diff --git a/packages/cli/src/utils/generate-client-telemetry.ts b/packages/cli/src/utils/generate-client-telemetry.ts index 32c6d3dd38..c4b5c3da03 100644 --- a/packages/cli/src/utils/generate-client-telemetry.ts +++ b/packages/cli/src/utils/generate-client-telemetry.ts @@ -79,14 +79,14 @@ export function categorizeGenerateClientError(message: string): string { } export type EjectGeneratorTelemetry = { - /** 'eject' | 'update' | 'guidance' | 'scaffold'. */ + /** 'eject' | 'update' | 'guidance' | 'architect'. */ eject_generator_action?: string; - /** Allowlisted built-in name only; scaffold and unknown names stay unnamed. */ + /** Allowlisted built-in name only; architect and unknown names stay unnamed. */ eject_generator_name?: string; /** Coarse outcome: success | conflicts | already-exists | missing-pristine | merge-tool-missing | unknown-generator | builtin-name | invalid-name. */ eject_generator_outcome?: string; eject_generator_conflicts?: number; }; -/** Populated by the eject/scaffold handlers; spread into the telemetry payload by the wrapper. */ +/** Populated by the eject/architect handlers; spread into the telemetry payload by the wrapper. */ export const ejectGeneratorTelemetry: EjectGeneratorTelemetry = {}; diff --git a/packages/cli/src/utils/telemetry.ts b/packages/cli/src/utils/telemetry.ts index 69d6617433..42a0ea65fb 100644 --- a/packages/cli/src/utils/telemetry.ts +++ b/packages/cli/src/utils/telemetry.ts @@ -152,7 +152,7 @@ export async function sendTelemetry({ ?.length ? JSON.stringify(generate_client.generate_client_ejected_generators) : undefined, - // eject-generator / scaffold-generator usage (action, allowlisted name, coarse + // eject-generator / architect-generator usage (action, allowlisted name, coarse // outcome — never user paths or user-chosen names). eject_generator_action: eject_generator?.eject_generator_action, eject_generator_name: eject_generator?.eject_generator_name, diff --git a/tests/e2e/generate-client/eject.test.ts b/tests/e2e/generate-client/eject.test.ts index 65ce996d5d..bf2ee504d9 100644 --- a/tests/e2e/generate-client/eject.test.ts +++ b/tests/e2e/generate-client/eject.test.ts @@ -34,7 +34,7 @@ function run(cwd: string, args: string[]) { return spawnSync('node', [cliEntry, ...args], { cwd, encoding: 'utf-8' }); } -describe('eject-generator / scaffold-generator (end-to-end)', () => { +describe('eject-generator / architect-generator (end-to-end)', () => { let project: string; beforeAll(() => { @@ -119,23 +119,23 @@ describe('eject-generator / scaffold-generator (end-to-end)', () => { expect(readFileSync(join(project, 'generators/php.mjs'), 'utf-8')).toContain('<<<<<<<'); }, 60_000); - it('scaffold-generator creates a runnable skeleton; built-in names are refused', () => { - const scaffold = run(project, ['scaffold-generator', 'route-map']); - expect(scaffold.status, scaffold.stderr).toBe(0); + it('architect-generator creates a runnable skeleton; built-in names are refused', () => { + const architect = run(project, ['architect-generator', 'route-map']); + expect(architect.status, architect.stderr).toBe(0); const generate = run(project, [ 'generate-client', 'openapi.yaml', '--output', - 'scaffolded/client.ts', + 'architected/client.ts', '--generator', 'sdk', '--generator', './generators/route-map.mjs', ]); expect(generate.status, generate.stderr).toBe(0); - expect(readFileSync(join(project, 'scaffolded/client.route-map.txt'), 'utf-8')).toContain( + expect(readFileSync(join(project, 'architected/client.route-map.txt'), 'utf-8')).toContain( 'GET /orders — listOrders' ); - expect(run(project, ['scaffold-generator', 'php']).status).not.toBe(0); + expect(run(project, ['architect-generator', 'php']).status).not.toBe(0); }, 60_000); }); diff --git a/tests/e2e/generate-client/examples.test.ts b/tests/e2e/generate-client/examples.test.ts index feb0e27d1e..3217e32b12 100644 --- a/tests/e2e/generate-client/examples.test.ts +++ b/tests/e2e/generate-client/examples.test.ts @@ -85,7 +85,7 @@ describe('examples generate with the current generator', () => { }); describe('generator-authoring examples carry the current AGENTS.md', () => { - // The eject/scaffold examples commit the AGENTS.md drop so browsers see the full + // The eject/architect examples commit the AGENTS.md drop so browsers see the full // story; this pins them byte-for-byte to the shipped template (markers included). const template = readFileSync( join(repoRoot, 'packages/client-generator/eject-assets/AGENTS.md'), @@ -93,12 +93,12 @@ describe('generator-authoring examples carry the current AGENTS.md', () => { ).trim(); const expected = `\n\n${template}\n\n\n`; - for (const example of ['ejected-generator', 'scaffolded-generator']) { + for (const example of ['ejected-generator', 'architected-generator']) { it(`${example}/generators/AGENTS.md matches the shipped template`, () => { const dropped = readFileSync(join(examplesDir, example, 'generators/AGENTS.md'), 'utf-8'); expect( dropped, - `stale — re-run \`redocly eject-generator\` or \`scaffold-generator\` in the example` + `stale — re-run \`redocly eject-generator\` or \`architect-generator\` in the example` ).toBe(expected); }); } diff --git a/tests/e2e/generate-client/examples/README.md b/tests/e2e/generate-client/examples/README.md index ce985b94fc..dbbf40516b 100644 --- a/tests/e2e/generate-client/examples/README.md +++ b/tests/e2e/generate-client/examples/README.md @@ -31,7 +31,7 @@ The generated client under `src/api/` is gitignored — CI regenerates every cli | [go-sdk](./go-sdk) | CLI · `go` | a full Go SDK (stdlib-only): typed structs, `(T, error)` methods, `context.Context` | | [php-sdk](./php-sdk) | CLI · `php` | a full PHP SDK (curl extension): promoted-constructor classes, native enums, named-argument methods | | [ejected-generator](./ejected-generator) | CLI · ejected `php` | `eject-generator php` vendored + customized: the path entry shadows the built-in name; regeneration keeps the customization | -| [scaffolded-generator](./scaffolded-generator) | CLI · `sdk` + scaffolded | `scaffold-generator` skeleton filled in — a markdown operations summary emitted next to the client | +| [architected-generator](./architected-generator) | CLI · `sdk` + architected | `architect-generator` skeleton filled in — a markdown operations summary emitted next to the client | ## Run one diff --git a/tests/e2e/generate-client/examples/scaffolded-generator/.gitignore b/tests/e2e/generate-client/examples/architected-generator/.gitignore similarity index 100% rename from tests/e2e/generate-client/examples/scaffolded-generator/.gitignore rename to tests/e2e/generate-client/examples/architected-generator/.gitignore diff --git a/tests/e2e/generate-client/examples/architected-generator/README.md b/tests/e2e/generate-client/examples/architected-generator/README.md new file mode 100644 index 0000000000..f9bec6c049 --- /dev/null +++ b/tests/e2e/generate-client/examples/architected-generator/README.md @@ -0,0 +1,12 @@ +# architected-generator + +`redocly architect-generator ops-summary` created the skeleton for `generators/ops-summary.mjs` plus `generators/AGENTS.md` (committed here) — the authoring guide your coding agent uses as context to fill the skeleton in; this example evolved it into a markdown operations summary emitted next to the client. +The generator reads the same API model the built-ins consume, so the summary regenerates with the spec and can never drift from it. + +```sh +npm run generate +cat src/api/client.operations.md +npm run architect # try the command yourself: architects a fresh generators/my-generator.mjs +``` + +To customize a built-in language generator instead of writing one from scratch, see the [`ejected-generator`](../ejected-generator) example. diff --git a/tests/e2e/generate-client/examples/scaffolded-generator/generators/AGENTS.md b/tests/e2e/generate-client/examples/architected-generator/generators/AGENTS.md similarity index 100% rename from tests/e2e/generate-client/examples/scaffolded-generator/generators/AGENTS.md rename to tests/e2e/generate-client/examples/architected-generator/generators/AGENTS.md diff --git a/tests/e2e/generate-client/examples/scaffolded-generator/generators/ops-summary.mjs b/tests/e2e/generate-client/examples/architected-generator/generators/ops-summary.mjs similarity index 92% rename from tests/e2e/generate-client/examples/scaffolded-generator/generators/ops-summary.mjs rename to tests/e2e/generate-client/examples/architected-generator/generators/ops-summary.mjs index c7a92a87b9..d90813fae8 100644 --- a/tests/e2e/generate-client/examples/scaffolded-generator/generators/ops-summary.mjs +++ b/tests/e2e/generate-client/examples/architected-generator/generators/ops-summary.mjs @@ -1,4 +1,4 @@ -// Scaffolded with `redocly scaffold-generator ops-summary`, then filled in: +// Architected with `redocly architect-generator ops-summary`, then filled in: // emits a markdown operations summary next to the client — an artifact no // built-in generator covers, derived from the same API model, so it can // never drift from the description. diff --git a/tests/e2e/generate-client/examples/scaffolded-generator/package.json b/tests/e2e/generate-client/examples/architected-generator/package.json similarity index 66% rename from tests/e2e/generate-client/examples/scaffolded-generator/package.json rename to tests/e2e/generate-client/examples/architected-generator/package.json index ee6f1d97fd..5f1ded4014 100644 --- a/tests/e2e/generate-client/examples/scaffolded-generator/package.json +++ b/tests/e2e/generate-client/examples/architected-generator/package.json @@ -1,10 +1,10 @@ { - "name": "@redocly-examples/scaffolded-generator", + "name": "@redocly-examples/architected-generator", "private": true, "version": "0.0.0", "type": "module", "scripts": { - "scaffold": "redocly scaffold-generator my-generator", + "architect": "redocly architect-generator my-generator", "generate": "redocly generate-client" }, "devDependencies": { diff --git a/tests/e2e/generate-client/examples/scaffolded-generator/redocly.yaml b/tests/e2e/generate-client/examples/architected-generator/redocly.yaml similarity index 90% rename from tests/e2e/generate-client/examples/scaffolded-generator/redocly.yaml rename to tests/e2e/generate-client/examples/architected-generator/redocly.yaml index b223fa605a..6276bcff71 100644 --- a/tests/e2e/generate-client/examples/scaffolded-generator/redocly.yaml +++ b/tests/e2e/generate-client/examples/architected-generator/redocly.yaml @@ -1,6 +1,6 @@ # redocly.yaml — drives `redocly generate-client` for this example. apis: - scaffolded-generator: + architected-generator: root: ../_shared/cafe.yaml clientOutput: ./src/api/client.ts client: diff --git a/tests/e2e/generate-client/examples/scaffolded-generator/README.md b/tests/e2e/generate-client/examples/scaffolded-generator/README.md deleted file mode 100644 index 5528fda202..0000000000 --- a/tests/e2e/generate-client/examples/scaffolded-generator/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# scaffolded-generator - -`redocly scaffold-generator ops-summary` created the skeleton for `generators/ops-summary.mjs` plus `generators/AGENTS.md` (committed here) — the authoring guide your coding agent uses as context to fill the skeleton in; this example evolved it into a markdown operations summary emitted next to the client. -The generator reads the same API model the built-ins consume, so the summary regenerates with the spec and can never drift from it. - -```sh -npm run generate -cat src/api/client.operations.md -npm run scaffold # try the command yourself: scaffolds a fresh generators/my-generator.mjs -``` - -To customize a built-in language generator instead of writing one from scratch, see the [`ejected-generator`](../ejected-generator) example. From 5c837dc5f1b9194475b1a7b1a712c8741081da0c Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 16:43:56 +0300 Subject: [PATCH 063/211] chore: consolidate branch changesets into one --- .changeset/agent-friendly-generators.md | 8 ++++++++ .changeset/agent-friendly-slice1.md | 6 ------ .changeset/cli-generator.md | 6 ------ .changeset/eject-architect-generators.md | 6 ------ .changeset/eject-telemetry.md | 6 ------ .changeset/go-generator.md | 6 ------ .changeset/harness-naming-fixes.md | 6 ------ .changeset/php-generator.md | 6 ------ .changeset/python-generator.md | 6 ------ .changeset/text-template-migration.md | 8 -------- 10 files changed, 8 insertions(+), 56 deletions(-) create mode 100644 .changeset/agent-friendly-generators.md delete mode 100644 .changeset/agent-friendly-slice1.md delete mode 100644 .changeset/cli-generator.md delete mode 100644 .changeset/eject-architect-generators.md delete mode 100644 .changeset/eject-telemetry.md delete mode 100644 .changeset/go-generator.md delete mode 100644 .changeset/harness-naming-fixes.md delete mode 100644 .changeset/php-generator.md delete mode 100644 .changeset/python-generator.md delete mode 100644 .changeset/text-template-migration.md diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md new file mode 100644 index 0000000000..7506931594 --- /dev/null +++ b/.changeset/agent-friendly-generators.md @@ -0,0 +1,8 @@ +--- +'@redocly/client-generator': minor +'@redocly/cli': minor +--- + +Added agent-friendly client generation: built-in `python`, `go`, `php`, and `cli` generators, a language-neutral authoring toolkit with a per-generator `AGENTS.md` skill, `eject-generator` and `architect-generator` commands, `x-codeSamples` output, and a real-world verification harness — with every generator now emitting through source-text templates. + +**Note:** the AST exports (`ts`, `printStatements`, `schemaToTypeNode`, …) were removed from `@redocly/client-generator/generate` in favor of the text toolkit (`tsType`, `tsJsdoc`, `codeLiteral`). diff --git a/.changeset/agent-friendly-slice1.md b/.changeset/agent-friendly-slice1.md deleted file mode 100644 index 63e3294f6e..0000000000 --- a/.changeset/agent-friendly-slice1.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@redocly/client-generator': minor -'@redocly/cli': minor ---- - -Added the language-neutral generator-authoring toolkit (`flattenAllOf`, `discriminatorCases`, nullability and enum helpers, casing/identifier utilities, and `CodeWriter`), available from the package root so custom generators in any output language never load TypeScript; the generation pipeline now loads built-in generators lazily. Generators can implement a `sample()` hook, and `client.codeSamples: true` emits an OpenAPI Overlay adding per-operation `x-codeSamples` (the TypeScript sdk ships the reference implementation). diff --git a/.changeset/cli-generator.md b/.changeset/cli-generator.md deleted file mode 100644 index be6d837b32..0000000000 --- a/.changeset/cli-generator.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@redocly/client-generator': minor -'@redocly/cli': minor ---- - -Added a built-in `cli` generator — `.cli.ts`, a bin-ready, zero-dependency command-line interface over the generated client: typed flags from query parameters, positional path parameters, `--json` bodies (inline, `@file`, or stdin), credentials from prefixed environment variables, `--dry-run`, `--page-all` pagination streaming, SSE and blob output, a documented exit-code contract, and zod request validation when the `zod` generator is co-selected. diff --git a/.changeset/eject-architect-generators.md b/.changeset/eject-architect-generators.md deleted file mode 100644 index 152b427b1c..0000000000 --- a/.changeset/eject-architect-generators.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@redocly/client-generator': minor -'@redocly/cli': minor ---- - -Added `redocly eject-generator` and `redocly architect-generator` — vendor a built-in language generator (`python`, `go`, `php`) into your repo as an editable file (with a pristine snapshot, three-way `--update` merges, and byte-identical output when unmodified), or architect a custom generator skeleton; both drop the `AGENTS.md` generator-authoring guide for coding agents. A path-loaded generator may now take over a built-in name, and the new `@redocly/client-generator/runtime-sources` entry serves the embedded-runtime sources to ejected generators. diff --git a/.changeset/eject-telemetry.md b/.changeset/eject-telemetry.md deleted file mode 100644 index 18e42b918b..0000000000 --- a/.changeset/eject-telemetry.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@redocly/client-generator': patch -'@redocly/cli': patch ---- - -Added coarse usage telemetry for the eject workflow (respecting the `REDOCLY_TELEMETRY` opt-out, documented on the usage-data page): `eject-generator`/`architect-generator` report the action and outcome category (such as clean or conflicted `--update` merges), `generate-client` reports the built-in origin and ejected-from version of path generators that carry the eject provenance header, and a generator that throws during a run is now reported as the `generator-run` error category with the failing generator named in the CLI error message. File contents, paths, and user-chosen names are never transmitted. diff --git a/.changeset/go-generator.md b/.changeset/go-generator.md deleted file mode 100644 index 6094b53e77..0000000000 --- a/.changeset/go-generator.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@redocly/client-generator': minor -'@redocly/cli': minor ---- - -Added a built-in `go` generator — a self-contained, zero-dependency Go SDK over the standard library with typed structs, enums, discriminated-union dispatchers, a context-aware client with `(T, error)` methods, auth, retries, timeouts, idempotency keys, middleware, pagination iterators (`Pages` / `Items` in `range`-over-func style), SSE streaming, and multipart bodies, plus Go `x-codeSamples`. Also added two language-neutral authoring helpers, `schemaAtPointer` and `paginationRuleFor`, shared by every generator. diff --git a/.changeset/harness-naming-fixes.md b/.changeset/harness-naming-fixes.md deleted file mode 100644 index 677a883050..0000000000 --- a/.changeset/harness-naming-fixes.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@redocly/client-generator': patch -'@redocly/cli': patch ---- - -Fixed three naming bugs found by generating clients from real-world API descriptions: strict-mode reserved words (such as `package`) are now sanitized in generated TypeScript, `+1`/`-1` property names become distinct `Plus1`/`Minus1` identifiers instead of colliding (the Python client silently dropped one of the fields), and digit-leading property names (such as `3ds`) produce exported Go struct fields instead of unexported ones that `encoding/json` ignores. diff --git a/.changeset/php-generator.md b/.changeset/php-generator.md deleted file mode 100644 index ce76cf9eed..0000000000 --- a/.changeset/php-generator.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@redocly/client-generator': minor -'@redocly/cli': minor ---- - -Added a built-in `php` generator — a self-contained, zero-dependency PHP 8.1+ SDK over the curl extension with promoted-constructor model classes, native backed enums, discriminated-union dispatchers, a client with typed named-argument methods, auth, retries, timeouts, idempotency keys, middleware, pagination generators (`Pages()` / `Items()`), SSE streaming, and multipart bodies, plus PHP `x-codeSamples`. diff --git a/.changeset/python-generator.md b/.changeset/python-generator.md deleted file mode 100644 index 772c9986cf..0000000000 --- a/.changeset/python-generator.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@redocly/client-generator': minor -'@redocly/cli': minor ---- - -Added a built-in `python` generator — a self-contained full Python SDK over httpx with typed dataclass models, sync and async clients, auth, retries, timeouts, idempotency keys, middleware, pagination iterators, SSE streaming, multipart bodies, and both error modes, plus Python `x-codeSamples`. Generating with only `python` selected does not require the `typescript` package. diff --git a/.changeset/text-template-migration.md b/.changeset/text-template-migration.md deleted file mode 100644 index f32e90ce37..0000000000 --- a/.changeset/text-template-migration.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@redocly/client-generator': minor -'@redocly/cli': minor ---- - -Every generator — including the TypeScript `sdk` and its satellites — is now authored with source-text templates instead of the TypeScript compiler AST, with byte-identical generated output. Generating a client no longer loads the `typescript` package for any selection (`--setup` baking remains the one lazy exception), and the `@redocly/client-generator/generate` toolkit now exports the text renderers the sdk itself uses (`tsType`, `tsJsdoc`, `codeLiteral`). - -**Note:** the AST exports (`ts`, `printStatements`, `parseStatements`, `printNodes`, `arrow`, `constArray`, `exportConstStatement`, `jsdoc`, `schemaToTypeNode`) were removed from `@redocly/client-generator/generate`, and `schemaToZodExpression` now returns source text instead of a `ts.Expression`. Custom generators built on the AST API should switch to the text toolkit — see the updated `ast-toolkit-generator` example. From e65e52f5c097314bb8e549bf2391acfde44a03d1 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 16:53:08 +0300 Subject: [PATCH 064/211] docs: trim AGENTS.md to non-derivable guidance, point core principles at the rules-system skill --- .claude/rules/core-principles.md | 2 +- AGENTS.md | 76 ++------------------------------ 2 files changed, 4 insertions(+), 74 deletions(-) diff --git a/.claude/rules/core-principles.md b/.claude/rules/core-principles.md index cf785aa508..3706d5b37f 100644 --- a/.claude/rules/core-principles.md +++ b/.claude/rules/core-principles.md @@ -19,7 +19,7 @@ Release and commit mechanics are procedures, not principles — they live in 1. Respect the core patterns: Walker, Visitors, and Nodes. New rules and decorators follow this pattern instead of using regex or manual drilling objects described by the supported specifications. - The full guide is in [`rules-system.md`](./rules-system.md). + The full guide is in [the `rules-system` skill](../skills/rules-system/SKILL.md). 1. Explain in chat, not in files. Don't create explanation, summary, or design files unless asked. diff --git a/AGENTS.md b/AGENTS.md index ea29dcee6a..63cf5546e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -62,49 +62,9 @@ npm run format npm run cli -- lint openapi.yaml ``` -## Architecture - -This is a TypeScript monorepo with npm workspaces containing four packages: - -### `packages/core` (@redocly/openapi-core) - -The heart of the project. -Handles all OpenAPI/AsyncAPI linting, validation, bundling, and decoration logic. -This package is also used in external apps such as `language-server` and `vs-code-extension`. - -Key directories: - -- `src/rules/` — Built-in linting rules, organized by spec type (`oas2/`, `oas3/`, `oas3_1/`, `async2/`, `async3/`, `arazzo/`, `common/`). Each rule is its own file. -- `src/config/` — Configuration loading and resolution (reads `redocly.yaml`). -- `src/decorators/` — Built-in decorators for transforming API descriptions. -- `src/bundle/` — Bundling logic that resolves `$ref` across multiple files. -- `src/resolve.ts` — Document resolution for multi-file specs (local and remote). -- `src/types/` — TypeScript type definitions for OAS2, OAS3, AsyncAPI, Arazzo. - -### `packages/cli` (@redocly/cli) - -User-facing CLI layer built on top of core. -Uses yargs for argument parsing. - -- `src/index.ts` — Main command dispatcher. -- `src/commands/` — One file per command. -- Commands use `commandWrapper()` for consistent output, config loading, config linting, and exit codes (0 = success, 1 = execution error, 2 = config error). - -### `packages/respect-core` (@redocly/respect-core) - -API contract testing framework. -Validates real API responses against OpenAPI/Arazzo specs. - -- `src/run.ts` — Test execution logic. -- `src/modules/` — Core testing modules, including runtime expression evaluation. - -### `packages/client-generator` (@redocly/client-generator) - -Experimental package for generating TypeScript clients from OpenAPI specs. - ## Build System -`packages/core`, `packages/respect-core`, and `packages/client-generator` are compiled by TypeScript (`tsc -b tsconfig.build.json`). +`packages/core` and `packages/respect-core` are compiled by TypeScript (`tsc -b tsconfig.build.json`). `packages/cli` is bundled by esbuild (`packages/cli/scripts/build.mjs`) — it produces `lib/index.js` (entry chunk, ~450 kB) and lazy chunks under `lib/chunks/` (redoc + react, loaded only when `build-docs` runs). The root `npm run compile` runs both steps: tsc for core/respect-core, then the esbuild bundle for the CLI. @@ -114,7 +74,7 @@ The published CLI package ships from a staged `.publish/` directory (created by Linting in `packages/core` rests on three concepts: the **Walker** traverses the parsed API description and resolves `$ref`s, **Visitors** are objects keyed by **Node** type, and the Walker calls each visitor's `enter` / `leave` / `skip` hooks as it reaches a node. New rules and decorators follow this pattern instead of parsing documents by hand. -The full guide, with examples, is in [`.claude/rules/rules-system.md`](./.claude/rules/rules-system.md). +The full guide, with examples, is in [the `rules-system` skill](./.claude/skills/rules-system/SKILL.md). ## Add or change a built-in rule @@ -148,36 +108,6 @@ Naming and reuse: The full testing and QA rules are in [`.claude/rules/testing.md`](./.claude/rules/testing.md). -The rule test pattern looks like this: - -```ts -import { outdent } from 'outdent'; -import { parseYamlToDocument, replaceSourceWithRef } from '../../../../__tests__/utils.js'; -import { createConfig } from '../../../config/index.js'; -import { lintDocument } from '../../../lint.js'; -import { BaseResolver } from '../../../resolve.js'; - -describe('Oas3 no-my-rule', () => { - it('should report a violation', async () => { - const document = parseYamlToDocument( - outdent` - openapi: 3.0.0 - ... - `, - 'foobar.yaml' - ); - - const results = await lintDocument({ - externalRefResolver: new BaseResolver(), - document, - config: await createConfig({ rules: { 'no-my-rule': 'error' } }), - }); - - expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`...`); - }); -}); -``` - ## Code quality — no AI slop Before opening a PR, strip the things an assistant tends to add that a human reviewer would not: @@ -207,7 +137,7 @@ The full release and commit workflow is in [`.claude/rules/workflow.md`](./.clau - Every feature or fix needs a changeset: run `npx changeset` and describe the change in sentence case. If the change lives in `packages/core` or `packages/respect-core` but affects CLI behavior, include `@redocly/cli` as well. - `@redocly/cli`, `@redocly/openapi-core`, and `@redocly/respect-core` share one version and release together; `@redocly/client-generator` is versioned separately. + All three packages share one version and release together. - Use [Conventional Commits](https://www.conventionalcommits.org/) for commit messages. - Don't add AI co-author or "Generated by" lines to commits. - Don't modify the pull request template. From e24a5a715e9a17dd2402699598b18df68418c88d Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 3 Aug 2026 18:42:01 +0300 Subject: [PATCH 065/211] chore: remove stale migration-narrative comments and a duplicated render pass --- .../scripts/generate-runtime-sources.mjs | 3 +-- .../client-generator/src/emitters/mock-value.ts | 3 +-- .../src/emitters/operation-types.ts | 3 +-- .../client-generator/src/emitters/render-client.ts | 13 +++++-------- .../client-generator/src/emitters/transformers.ts | 9 +++------ .../client-generator/src/emitters/ts-literal.ts | 7 +++---- packages/client-generator/src/emitters/ts-type.ts | 9 +++------ .../client-generator/src/emitters/type-guards.ts | 10 ++++------ packages/client-generator/src/pipeline.ts | 4 +++- 9 files changed, 24 insertions(+), 37 deletions(-) diff --git a/packages/client-generator/scripts/generate-runtime-sources.mjs b/packages/client-generator/scripts/generate-runtime-sources.mjs index 29ab7f14be..16c1176e82 100644 --- a/packages/client-generator/scripts/generate-runtime-sources.mjs +++ b/packages/client-generator/scripts/generate-runtime-sources.mjs @@ -129,8 +129,7 @@ writeFileSync( // Stripped variants for inline embedding (emitters/inline-runtime.ts): imports dropped, // `export` removed except on the kept surface — done HERE at prepare time so the embed // path needs no TypeScript at generate time. Slices are AST-position-driven (no regexes), -// so comments and formatting survive byte-for-byte — the algorithm previously lived in -// inline-runtime.ts's embedModule and moved here verbatim. +// so comments and formatting survive byte-for-byte. const KEEP_EXPORTS = { 'types.ts': () => true, 'errors.ts': (statement) => ts.isClassDeclaration(statement), diff --git a/packages/client-generator/src/emitters/mock-value.ts b/packages/client-generator/src/emitters/mock-value.ts index 802e286f20..b06606741e 100644 --- a/packages/client-generator/src/emitters/mock-value.ts +++ b/packages/client-generator/src/emitters/mock-value.ts @@ -1,7 +1,6 @@ // The value tree the mock/faker emitters build and render: keeps object structure // (for intersection merging and `...overrides` spreading) until the final render, -// where indentation is threaded — the text-template equivalent of passing -// `ts.ObjectLiteralExpression` around. Deliberately tiny. +// where indentation is threaded. Deliberately tiny. import { safeIdent } from './identifier.js'; diff --git a/packages/client-generator/src/emitters/operation-types.ts b/packages/client-generator/src/emitters/operation-types.ts index 951cd00d54..9c670d038f 100644 --- a/packages/client-generator/src/emitters/operation-types.ts +++ b/packages/client-generator/src/emitters/operation-types.ts @@ -1,5 +1,4 @@ -// Shared operation-shape predicates. The type/parameter RENDERING that used to -// live here moved to the text templates in render-client.ts. +// Shared operation-shape predicates. import type { RequestBodyModel } from '../intermediate-representation/model.js'; diff --git a/packages/client-generator/src/emitters/render-client.ts b/packages/client-generator/src/emitters/render-client.ts index ad4edaebef..b539cc2a26 100644 --- a/packages/client-generator/src/emitters/render-client.ts +++ b/packages/client-generator/src/emitters/render-client.ts @@ -1,9 +1,6 @@ -// The text-template client assembly — a DEEP module: its lasting public surface is -// the same two functions client-assembly.ts exposes today (single-file / split -// emission); everything below is internal plumbing that used to be spread across -// operation-types / operation-aliases / descriptor as AST builders. The part -// renderers are exported for the printer-equivalence tests only, and the exports -// shrink to the assembly seam when the flip lands. +// 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, @@ -208,7 +205,7 @@ function errorArgText(op: OperationModel, ctx: EmitContext, indent: string): str return members.join(' | '); } -/** The `Ops` type map — text twin of `opsInterfaceStatements` (printer-equivalence-pinned). */ +/** `export type Ops = { : { args; result; item?; page?; kind? } }` — what `createClient` consumes. */ export function renderOpsType( model: ApiModel, idents: Map, @@ -260,7 +257,7 @@ export function renderOpsType( ].join('\n'); } -/** One operation's `*` aliases — text twin of the alias cluster (equivalence-pinned). */ +/** One operation's `*` aliases (Result/Error/Params/Body/Headers/Cookies/Variables), collision-suppressed. */ export function renderAliases( op: OperationModel, ctx: EmitContext, diff --git a/packages/client-generator/src/emitters/transformers.ts b/packages/client-generator/src/emitters/transformers.ts index e4ec94587b..b913f91fcf 100644 --- a/packages/client-generator/src/emitters/transformers.ts +++ b/packages/client-generator/src/emitters/transformers.ts @@ -457,16 +457,13 @@ function convertCollection( } } const next = nextItemVar(itemVar); - const body = convert(ident(next), element, byName, seen, next, indent + INDENT); + // The loop sits one `if` level in, and the forEach body one more. + const body = convert(ident(next), element, byName, seen, next, indent + INDENT + INDENT); if (body.length === 0) return []; const iterable = isRecord ? `Object.values(${target.text})` : target.text; return ifThen( isRecord ? target.text : `Array.isArray(${target.text})`, - (inner) => [ - `${inner}${iterable}.forEach(${next} => {`, - ...convert(ident(next), element, byName, seen, next, inner + INDENT), - `${inner}});`, - ], + (inner) => [`${inner}${iterable}.forEach(${next} => {`, ...body, `${inner}});`], indent ); } diff --git a/packages/client-generator/src/emitters/ts-literal.ts b/packages/client-generator/src/emitters/ts-literal.ts index 6a53d89aac..f41679b393 100644 --- a/packages/client-generator/src/emitters/ts-literal.ts +++ b/packages/client-generator/src/emitters/ts-literal.ts @@ -1,7 +1,6 @@ -// Plain data → TypeScript expression text: the template-based replacement for -// `literalExpression` + the printer. Single-line, printer-matching formatting -// (`{ a: 1, b: [2, 3] }`); keys stay bare when they pass the identifier GRAMMAR -// (reserved words are legal object-literal keys), quoted otherwise. +// Plain data → TypeScript expression text. Single-line (`{ a: 1, b: [2, 3] }`); +// keys stay bare when they pass the identifier GRAMMAR (reserved words are legal +// object-literal keys), quoted otherwise. import { isIdentifier } from './identifier.js'; diff --git a/packages/client-generator/src/emitters/ts-type.ts b/packages/client-generator/src/emitters/ts-type.ts index 6d1461e312..27f410fe2e 100644 --- a/packages/client-generator/src/emitters/ts-type.ts +++ b/packages/client-generator/src/emitters/ts-type.ts @@ -1,9 +1,6 @@ -// TypeScript TYPES as source text — the template-based replacement for the AST -// printer path (`schemaToTypeNode` + `printNodes`). Pure string logic over the -// IR: no `typescript` import, so the sdk generator joins the same TS-free -// authoring model as python/go/php. Formatting matches the printer (4-space -// indent, double quotes, union/intersection parenthesization) so the migration -// does not churn generated output shape. +// TypeScript TYPES as source text: pure string logic over the IR, no +// `typescript` import. Formatting contract: 4-space indent, double-quoted +// literals, compound members parenthesized inside unions/intersections/arrays. import type { NamedSchemaModel, diff --git a/packages/client-generator/src/emitters/type-guards.ts b/packages/client-generator/src/emitters/type-guards.ts index 0690208256..747323dad3 100644 --- a/packages/client-generator/src/emitters/type-guards.ts +++ b/packages/client-generator/src/emitters/type-guards.ts @@ -6,18 +6,16 @@ import type { /** * A discriminated union we can emit guards for, found while walking the schema - * tree. `makeParamType` builds the guard's `value` parameter type — the named - * union for a top-level union (`MenuItem`), or the inline member union for one - * nested inside another schema (`SuccessItem | ErrorItem`). `label` is the same, - * rendered for the JSDoc line. A thunk (not a cached node) avoids reusing one - * `ts.TypeNode` across the several guard declarations a site produces. + * tree. `label` is the guard's `value` parameter type — the union's name for a + * top-level union (`MenuItem`), the inline member union (`SuccessItem | ErrorItem`) + * for one nested inside another schema. */ type UnionSite = { union: Extract; label: string; }; -/** Text twin of `typeGuardStatements` (printer-equivalence-pinned); same detection, string body. */ +/** `is(value): value is ` guards for every discriminated union (explicit or implicit). */ export function renderTypeGuards(schemas: NamedSchemaModel[]): string { const byName = new Map(schemas.map((s) => [s.name, s.schema] as const)); const blocks: string[] = []; diff --git a/packages/client-generator/src/pipeline.ts b/packages/client-generator/src/pipeline.ts index be08b6b72e..656802e2e6 100644 --- a/packages/client-generator/src/pipeline.ts +++ b/packages/client-generator/src/pipeline.ts @@ -126,7 +126,9 @@ export async function generateClient( const { document, version } = await loadSpec(options.api, options.config); const normalized = version === 'oas2' - ? normalizeSwagger2(document as unknown as Record) + ? // loadSpec types the parsed document as OAS3 for the common path; a detected + // swagger-2 document is re-viewed as raw data for normalization. + normalizeSwagger2(document as unknown as Record) : document; const model = buildApiModel(normalized); From 0e85d36de34bc08cefded82dbfd080a0cf6d17dc Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 4 Aug 2026 12:24:13 +0300 Subject: [PATCH 066/211] feat: close service-based PHP SDK adoption gaps (binary bodies, union hydration, Servers class, mockable Client) --- .oxfmtrc.json | 1 + .../scripts/generate-runtime-sources.mjs | 19 +++- .../src/authoring/__tests__/naming.test.ts | 7 ++ .../client-generator/src/authoring/naming.ts | 3 + .../src/generators/__tests__/php.test.ts | 98 ++++++++++++++++++- .../src/generators/java/AGENTS.md | 2 +- .../src/generators/php/AGENTS.md | 29 +++++- .../src/generators/php/index.ts | 94 +++++++++++++++++- .../src/generators/python/AGENTS.md | 2 +- .../src/intermediate-representation/build.ts | 10 ++ .../src/intermediate-representation/model.ts | 14 +++ .../generators/php.AGENTS.md | 29 +++++- 12 files changed, 296 insertions(+), 12 deletions(-) diff --git a/.oxfmtrc.json b/.oxfmtrc.json index fc6793d2c6..c3005c873d 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -11,6 +11,7 @@ "packages/core/src/rules/common/__tests__/fixtures/invalid-yaml.yaml", "tests/performance/api-definitions/", "tests/e2e/generate-client/examples/*/src/api/", + "tests/e2e/generate-client/examples/*/generators/", "tests/e2e/generate-client/*-consumer/api*.ts", "tests/smoke/**/*.yaml", "snapshot*.txt", diff --git a/packages/client-generator/scripts/generate-runtime-sources.mjs b/packages/client-generator/scripts/generate-runtime-sources.mjs index 16c1176e82..b3517275d9 100644 --- a/packages/client-generator/scripts/generate-runtime-sources.mjs +++ b/packages/client-generator/scripts/generate-runtime-sources.mjs @@ -77,7 +77,16 @@ function declaredNames() { // The Python runtime (python-runtime/*.py) embeds the same way: hand-authored // once, stitched into every generated Python client by the python generator. -const PYTHON_MODULES = ['_errors', '_auth', '_url', '_decode', '_send', '_paginate', '_sse', '_multipart']; +const PYTHON_MODULES = [ + '_errors', + '_auth', + '_url', + '_decode', + '_send', + '_paginate', + '_sse', + '_multipart', +]; const pythonDir = join(pkgRoot, 'python-runtime'); const pythonOut = join(pkgRoot, 'src', 'emitters', 'python-runtime-sources.ts'); const pythonEntries = PYTHON_MODULES.map((name) => { @@ -139,7 +148,13 @@ const KEEP_EXPORTS = { }; function stripModule(name, source) { - const file = ts.createSourceFile('__embed.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const file = ts.createSourceFile( + '__embed.ts', + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS + ); const keeps = KEEP_EXPORTS[name]; const parts = []; for (const statement of file.statements) { diff --git a/packages/client-generator/src/authoring/__tests__/naming.test.ts b/packages/client-generator/src/authoring/__tests__/naming.test.ts index 046800cda2..5f010d76fe 100644 --- a/packages/client-generator/src/authoring/__tests__/naming.test.ts +++ b/packages/client-generator/src/authoring/__tests__/naming.test.ts @@ -12,6 +12,13 @@ describe('casing', () => { expect(casing.pascal('api_key_v2')).toBe('ApiKeyV2'); }); + it('keeps a plural acronym as one word (Rebilly title "All APIs")', () => { + expect(casing.pascal('All APIs')).toBe('AllApis'); + expect(casing.snake('externalIDs')).toBe('external_ids'); + // A real word after the acronym still splits. + expect(casing.pascal('APIServer')).toBe('ApiServer'); + }); + it('names signed numbers Plus*/Minus* so +1 and -1 stay distinct (GitHub reactions)', () => { expect(casing.pascal('+1')).toBe('Plus1'); expect(casing.pascal('-1')).toBe('Minus1'); diff --git a/packages/client-generator/src/authoring/naming.ts b/packages/client-generator/src/authoring/naming.ts index 110e5ddb6e..d1d1b3edd7 100644 --- a/packages/client-generator/src/authoring/naming.ts +++ b/packages/client-generator/src/authoring/naming.ts @@ -11,6 +11,9 @@ function splitWords(name: string): string[] { // reactions) must not collapse to the same identifier. .replace(/^\+(?=\d)/, 'plus ') .replace(/^-(?=\d)/, 'minus ') + // A plural acronym is one word: fold the trailing 's' in so the + // acronym-boundary rule below doesn't split 'APIs' into 'AP Is'. + .replace(/([A-Z]{2,})s(?![a-z])/g, '$1S') .replace(/([a-z0-9])([A-Z])/g, '$1 $2') .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') .split(/[^A-Za-z0-9]+/) diff --git a/packages/client-generator/src/generators/__tests__/php.test.ts b/packages/client-generator/src/generators/__tests__/php.test.ts index d253b33ac6..4dbd3f4506 100644 --- a/packages/client-generator/src/generators/__tests__/php.test.ts +++ b/packages/client-generator/src/generators/__tests__/php.test.ts @@ -142,6 +142,45 @@ describe('renderPhpModels', () => { expectModelsRun(out); }); + it('hydrates discriminated-union properties through the dispatcher so instanceof works', () => { + const out = renderPhpModels( + model({ + Cat: { kind: 'object', properties: [] }, + Dog: { kind: 'object', properties: [] }, + Pet: { + kind: 'union', + members: [ + { kind: 'ref', name: 'Cat' }, + { kind: 'ref', name: 'Dog' }, + ], + discriminator: { + propertyName: 'petType', + mapping: [ + { value: 'cat', schemaName: 'Cat' }, + { value: 'dog', schemaName: 'Dog' }, + ], + }, + }, + Owner: { + kind: 'object', + properties: [ + { name: 'pet', schema: { kind: 'ref', name: 'Pet' }, required: true }, + { + name: 'pets', + schema: { kind: 'array', items: { kind: 'ref', name: 'Pet' } }, + required: false, + }, + ], + }, + }) + ); + expect(out).toContain("pet: unmarshalPet($data['pet'])"); + expect(out).toContain('array_map(static fn ($item) => unmarshalPet($item)'); + // Serialization must accept both hydrated instances and raw arrays. + expect(out).toContain('is_object($this->pet) ? $this->pet->toArray() : $this->pet'); + expectModelsRun(out); + }); + it('maps nullability and reserved names idiomatically', () => { const out = renderPhpModels( model({ @@ -168,7 +207,19 @@ describe('renderPhpModels', () => { const CAFE: ApiModel = { title: 'Cafe Orders API', version: '1.0.0', - serverUrl: 'https://api.cafe.example', + serverUrl: 'https://api.cafe.example/organizations/unknown', + servers: [ + { + url: 'https://api.cafe.example/organizations/{organizationId}', + description: 'Live server', + variables: [{ name: 'organizationId', default: 'unknown' }], + }, + { + url: 'https://api-sandbox.cafe.example/organizations/{organizationId}', + description: 'Sandbox server', + variables: [{ name: 'organizationId', default: 'unknown' }], + }, + ], services: [ { name: 'Orders', @@ -222,6 +273,26 @@ const CAFE: ApiModel = { ], errorResponses: [], }, + { + name: 'getOrderPdf', + specName: 'getOrderPdf', + method: 'get', + path: '/orders/{orderId}/pdf', + tags: ['Orders'], + pathParams: [{ name: 'orderId', in: 'path', required: true, schema: STRING }], + queryParams: [], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { + status: '200', + contentType: 'application/pdf', + schema: { kind: 'scalar', scalar: 'string', metadata: { format: 'binary' } }, + }, + ], + errorResponses: [], + }, { name: 'createOrder', specName: 'createOrder', @@ -330,7 +401,9 @@ describe('phpGenerator (full client assembly)', () => { expect(out.startsWith(' { expect(out).toContain('toMultipart($body)'); expectPhpRuns(out); }); + + it('returns the raw body string for non-JSON success responses (PDF download)', () => { + const out = generatePhp(); + expect(out).toContain( + 'public function getOrderPdf(string $orderId, ?array $headers = null): string' + ); + expect(out).toContain("return $response['body'];"); + }); + + it('emits a Servers class with named variable arguments defaulting to the spec defaults', () => { + const out = generatePhp(); + expect(out).toContain('final class Servers'); + expect(out).toContain( + "public static function liveServer(string $organizationId = 'unknown'): string" + ); + expect(out).toContain( + "public static function sandboxServer(string $organizationId = 'unknown'): string" + ); + expect(out).toContain("return 'https://api.cafe.example/organizations/' . $organizationId;"); + expectPhpRuns(out); + }); }); diff --git a/packages/client-generator/src/generators/java/AGENTS.md b/packages/client-generator/src/generators/java/AGENTS.md index b899b0cf02..9e205adbaa 100644 --- a/packages/client-generator/src/generators/java/AGENTS.md +++ b/packages/client-generator/src/generators/java/AGENTS.md @@ -60,7 +60,7 @@ switch patterns), HTTP over `java.net.http.HttpClient` — part of the JDK since `Last-Event-ID` reconnect), multipart (hand-built body), `X-Redocly-Client` header. - The runtime is hand-written in `java-runtime/` and embedded at prepare time; verified with `javac` (and the smoke against the shared mock server). Harness gains a `javaBar` - (`javac` on Rebilly + GitHub output). GitHub CI runners ship a JDK. + (`javac` on the large real-world descriptions' output). CI runners ship a JDK. - Authored ONLY with the neutral toolkit — the dogfooding guard extends to `java/index.ts`. ## The modify loop (once implemented) diff --git a/packages/client-generator/src/generators/php/AGENTS.md b/packages/client-generator/src/generators/php/AGENTS.md index 0f61b1f877..542bedf7fc 100644 --- a/packages/client-generator/src/generators/php/AGENTS.md +++ b/packages/client-generator/src/generators/php/AGENTS.md @@ -10,7 +10,7 @@ covering sentence here is incomplete. One self-contained `.php`: promoted-constructor model classes, a `Client` with one typed method per operation, and the embedded runtime. PHP ≥ 8.1, HTTP over the curl extension — zero Composer dependencies. The namespace derives from the API title -(`identifierFor(title, pascal)` — e.g. `RedoclyCafe`). +(`identifierFor(title, pascal)` — e.g. `CafeOrders`). ## Design decisions that must hold @@ -18,7 +18,12 @@ extension — zero Composer dependencies. The namespace derives from the API tit first, optionals nullable `= null`. Hydration is compile-time generated per class: `fromArray(array $data): self` and `toArray(): array` (wire names inline; nulls skipped on serialize) — no reflection. `omit` schemas hydrate/serialize through their - base class. + base class. A property or response typed as a DISCRIMINATED union hydrates through the + union's `unmarshalX` dispatcher, so consumers can narrow with `instanceof`; + undiscriminated unions stay raw arrays. +- The `Client` class is NOT `final` — PHP test suites mock concrete classes + (`createMock(Client::class)`), and `final` would force a wrapper interface on every + consumer. Model classes stay `final`. - **Naming:** classes PascalCase, properties/methods camelCase via `identifierFor(..., RESERVED_WORDS.php)`; reserved words get a trailing underscore. - **Enums** are native backed enums (string/int); other scalars stay aliases. @@ -29,6 +34,13 @@ extension — zero Composer dependencies. The namespace derives from the API tit - **Method arguments:** required path params positional, JSON body next, optional query params as nullable NAMED arguments, then `?array $headers`, and `?string $idempotencyKey` on mutating methods. +- **Non-JSON success bodies** (PDFs, images, octet streams) return the raw body as + `string` — a binary download must never degrade to `void`. +- **Servers:** when the description declares servers, a `Servers` class is emitted with + one static method per server; server VARIABLES become named string arguments defaulting + to the spec's defaults (`Servers::production(organizationId: 'org_x')`), so templated + base URLs need no manual string building. The client's baked default stays `servers[0]` + with variable defaults substituted. - **Parity surface:** auth, retries with `Retry-After` + jittered backoff, per-attempt curl timeouts, middleware callables, pagination (`Pages()` / `Items()` as `\Generator`s), SSE (`iterSse` over a curl_multi pump), multipart. @@ -36,6 +48,19 @@ $idempotencyKey` on mutating methods. at prepare time. `curl_close` is never called (deprecated since PHP 8.5, no-op since 8.0). - Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise. +## Migrating from a service-based SDK + +- Per-resource services (`$client->customers()->get($id)`) map to flat methods named + after operationIds (`$client->getCustomer($id)`); optional query params keep their + named-argument style (`filter:`, `sort:`, `limit:`). +- Collection wrappers exposing pagination RESPONSE HEADERS (`getTotalItems()`, + `getLimit()`) have no equivalent — migrate to `Items()` / `Pages()` + generators, or capture headers with a middleware callable. +- Dedicated validation-exception classes exposing field errors map to + `catch (ApiError $e)` + `$e->status === 422` + the decoded `$e->body`. +- Session/bearer token flows map to `auth: ['bearer' => $tokenProvider]` with a + callable — resolved per request, so refresh needs no client rebuild. + ## The modify loop 1. Edit this skill: state the new behavior or decision. diff --git a/packages/client-generator/src/generators/php/index.ts b/packages/client-generator/src/generators/php/index.ts index cec8efa1f6..797522c132 100644 --- a/packages/client-generator/src/generators/php/index.ts +++ b/packages/client-generator/src/generators/php/index.ts @@ -25,6 +25,7 @@ import type { OperationModel, PropertyModel, SchemaModel, + ServerModel, } from '../../intermediate-representation/model.js'; import type { CodeSample, Generator, SampleContext } from '../types.js'; @@ -115,6 +116,12 @@ export function phpType(schema: SchemaModel, model: ApiModel): string { } } +/** 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. */ function hydration(schema: SchemaModel, expr: string, model: ApiModel): string | undefined { const bare = unwrapNullable(schema); @@ -123,6 +130,7 @@ function hydration(schema: SchemaModel, expr: string, model: ApiModel): string | 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); } @@ -147,6 +155,10 @@ function serialization(schema: SchemaModel, expr: string, model: ApiModel): stri 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); } @@ -456,8 +468,19 @@ function writePhpMethod(writer: Printer, op: OperationModel, model: ApiModel): v const args = methodArgs(op, model, true); const sse = sseResponse(op); const success = successSchema(op); + // Non-JSON success bodies (PDFs, images, octet streams) return the raw body string. + const rawBody = + sse === undefined && + success === undefined && + op.successResponses.some((response) => response.contentType !== ''); const returnType = - sse !== undefined ? '\\Generator' : success === undefined ? 'void' : phpType(success, model); + sse !== undefined + ? '\\Generator' + : success !== undefined + ? phpType(success, model) + : rawBody + ? 'string' + : 'void'; writeDocComment(writer, methodName(op), op.summary ?? `${op.method.toUpperCase()} ${op.path}`); writer.block( `public function ${methodName(op)}(${args.signature.join(', ')}): ${returnType}`, @@ -520,6 +543,10 @@ function writePhpMethod(writer: Printer, op: OperationModel, model: ApiModel): v }, '}' ); + if (rawBody) { + writer.line("return $response['body'];"); + return; + } if (returnType === 'void') { writer.line('decodeJson($response);'); return; @@ -643,6 +670,67 @@ function writePhpPaginationWrappers( writer.blank(); } +/** The server URL as a PHP expression: literals concatenated with declared-variable arguments. */ +function serverUrlExpression(server: ServerModel): string { + const declared = new Set(server.variables.map((variable) => variable.name)); + const parts: string[] = []; + let literal = ''; + let rest = server.url; + const template = /\{([^{}]+)\}/; + for (let match = template.exec(rest); match !== null; match = template.exec(rest)) { + literal += rest.slice(0, match.index); + if (declared.has(match[1])) { + if (literal !== '') parts.push(phpString(literal)); + literal = ''; + parts.push(`${'$'}${propertyName(match[1])}`); + } else { + // An undeclared variable has nothing to substitute; keep its placeholder visible. + literal += match[0]; + } + rest = rest.slice(match.index + match[0].length); + } + literal += rest; + if (literal !== '' || parts.length === 0) parts.push(phpString(literal)); + return parts.join(' . '); +} + +/** One static method per declared server; server variables become named string arguments. */ +function writeServers(writer: Printer, model: ApiModel): void { + const servers = model.servers ?? []; + if (servers.length === 0) return; + const usedNames = new Set(); + writer.line('/** The declared servers; variables default to the values from the description. */'); + writer.block('final class Servers', () => {}, ''); + writer.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) writer.blank(); + writer.block(`public static function ${name}(${params.join(', ')}): string`, () => {}, ''); + writer.block( + '{', + () => { + writer.line(`return ${serverUrlExpression(server)};`); + }, + '}' + ); + }); + }, + '}' + ); + writer.blank(); +} + /** Drop the standalone header ( { writer.line(`namespace ${namespace};`); writer.blank(); writer.line(renderPhpModels(model)); + writeServers(writer, model); writer.line('// ─── Embedded runtime (@redocly/client-generator php runtime) ───'); writer.line(stripPhpHeader(PHP_RUNTIME_SOURCE)); writer.blank(); @@ -708,7 +797,8 @@ export const phpGenerator: Generator = ({ model, outputPath, emit }) => { writer.blank(); writeDocComment(writer, 'Client', `Client for ${model.title} (${model.version}).`); - writer.block('final class Client', () => {}, ''); + // Not final: PHP test suites mock concrete classes (createMock(Client::class)). + writer.block('class Client', () => {}, ''); writer.block( '{', () => { diff --git a/packages/client-generator/src/generators/python/AGENTS.md b/packages/client-generator/src/generators/python/AGENTS.md index 99db230d10..35b505a0cc 100644 --- a/packages/client-generator/src/generators/python/AGENTS.md +++ b/packages/client-generator/src/generators/python/AGENTS.md @@ -42,4 +42,4 @@ One self-contained `.py`: typed dataclass models, a sync `Client` and an a 3. Verify: `npm run compile`, then `VITEST_SUITE=unit npx vitest run packages/client-generator/src/generators/__tests__/python.test.ts` (real `py_compile` bars), the e2e smoke (`tests/e2e/generate-client/python.test.ts`), - and `npm run harness` (Rebilly + GitHub import bars). + and `npm run harness` (import bars over the large real-world descriptions). diff --git a/packages/client-generator/src/intermediate-representation/build.ts b/packages/client-generator/src/intermediate-representation/build.ts index a08a9ee84c..b31d34c905 100644 --- a/packages/client-generator/src/intermediate-representation/build.ts +++ b/packages/client-generator/src/intermediate-representation/build.ts @@ -221,6 +221,15 @@ export function buildApiModel(doc: Oas3Definition): ApiModel { const version = doc.info?.version ?? '0.0.0'; const description = doc.info?.description; const serverUrl = resolveServerUrl(doc.servers?.[0]); + const servers = (doc.servers ?? []).map((server) => ({ + url: server.url, + description: server.description, + variables: Object.entries(server.variables ?? {}).map(([name, variable]) => ({ + name, + default: variable.default, + description: variable.description, + })), + })); const schemas = buildNamedSchemas(doc); const securitySchemes = buildSecuritySchemes(doc); @@ -231,6 +240,7 @@ export function buildApiModel(doc: Oas3Definition): ApiModel { version, description, serverUrl, + servers, services, schemas, securitySchemes, diff --git a/packages/client-generator/src/intermediate-representation/model.ts b/packages/client-generator/src/intermediate-representation/model.ts index 9517263734..0a1379d7dc 100644 --- a/packages/client-generator/src/intermediate-representation/model.ts +++ b/packages/client-generator/src/intermediate-representation/model.ts @@ -240,11 +240,25 @@ export type NamedSchemaModel = { description?: string; }; +export type ServerVariableModel = { + name: string; + default: string; + description?: string; +}; + +/** One declared server, URL kept TEMPLATED — `serverUrl` carries the substituted default. */ +export type ServerModel = { + url: string; + description?: string; + variables: ServerVariableModel[]; +}; + export type ApiModel = { title: string; version: string; description?: string; serverUrl: string; + servers?: ServerModel[]; services: ServiceModel[]; schemas: NamedSchemaModel[]; securitySchemes: SecuritySchemeModel[]; diff --git a/tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md b/tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md index 0f61b1f877..542bedf7fc 100644 --- a/tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md +++ b/tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md @@ -10,7 +10,7 @@ covering sentence here is incomplete. One self-contained `.php`: promoted-constructor model classes, a `Client` with one typed method per operation, and the embedded runtime. PHP ≥ 8.1, HTTP over the curl extension — zero Composer dependencies. The namespace derives from the API title -(`identifierFor(title, pascal)` — e.g. `RedoclyCafe`). +(`identifierFor(title, pascal)` — e.g. `CafeOrders`). ## Design decisions that must hold @@ -18,7 +18,12 @@ extension — zero Composer dependencies. The namespace derives from the API tit first, optionals nullable `= null`. Hydration is compile-time generated per class: `fromArray(array $data): self` and `toArray(): array` (wire names inline; nulls skipped on serialize) — no reflection. `omit` schemas hydrate/serialize through their - base class. + base class. A property or response typed as a DISCRIMINATED union hydrates through the + union's `unmarshalX` dispatcher, so consumers can narrow with `instanceof`; + undiscriminated unions stay raw arrays. +- The `Client` class is NOT `final` — PHP test suites mock concrete classes + (`createMock(Client::class)`), and `final` would force a wrapper interface on every + consumer. Model classes stay `final`. - **Naming:** classes PascalCase, properties/methods camelCase via `identifierFor(..., RESERVED_WORDS.php)`; reserved words get a trailing underscore. - **Enums** are native backed enums (string/int); other scalars stay aliases. @@ -29,6 +34,13 @@ extension — zero Composer dependencies. The namespace derives from the API tit - **Method arguments:** required path params positional, JSON body next, optional query params as nullable NAMED arguments, then `?array $headers`, and `?string $idempotencyKey` on mutating methods. +- **Non-JSON success bodies** (PDFs, images, octet streams) return the raw body as + `string` — a binary download must never degrade to `void`. +- **Servers:** when the description declares servers, a `Servers` class is emitted with + one static method per server; server VARIABLES become named string arguments defaulting + to the spec's defaults (`Servers::production(organizationId: 'org_x')`), so templated + base URLs need no manual string building. The client's baked default stays `servers[0]` + with variable defaults substituted. - **Parity surface:** auth, retries with `Retry-After` + jittered backoff, per-attempt curl timeouts, middleware callables, pagination (`Pages()` / `Items()` as `\Generator`s), SSE (`iterSse` over a curl_multi pump), multipart. @@ -36,6 +48,19 @@ $idempotencyKey` on mutating methods. at prepare time. `curl_close` is never called (deprecated since PHP 8.5, no-op since 8.0). - Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise. +## Migrating from a service-based SDK + +- Per-resource services (`$client->customers()->get($id)`) map to flat methods named + after operationIds (`$client->getCustomer($id)`); optional query params keep their + named-argument style (`filter:`, `sort:`, `limit:`). +- Collection wrappers exposing pagination RESPONSE HEADERS (`getTotalItems()`, + `getLimit()`) have no equivalent — migrate to `Items()` / `Pages()` + generators, or capture headers with a middleware callable. +- Dedicated validation-exception classes exposing field errors map to + `catch (ApiError $e)` + `$e->status === 422` + the decoded `$e->body`. +- Session/bearer token flows map to `auth: ['bearer' => $tokenProvider]` with a + callable — resolved per request, so refresh needs no client rebuild. + ## The modify loop 1. Edit this skill: state the new behavior or decision. From f9beb13e81a46a6ea48e44eb843ebc1e9efa82f0 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 4 Aug 2026 12:57:25 +0300 Subject: [PATCH 067/211] test: fold the generate-client harness into the e2e suite as large-description bars --- .changeset/agent-friendly-generators.md | 2 +- .github/workflows/harness.yaml | 48 -------------- .github/workflows/tests.yaml | 19 ++++-- package.json | 1 - .../__tests__/generator-skills.test.ts | 2 +- .../src/generators/go/AGENTS.md | 2 +- .../src/generators/java/AGENTS.md | 6 +- .../src/generators/php/AGENTS.md | 2 +- .../src/generators/python/AGENTS.md | 2 +- .../generate-client}/.gitignore | 0 .../generators/php.AGENTS.md | 2 +- .../large-descriptions.test.ts} | 66 +++++++++++++------ .../harness/generate-client/github.harness.ts | 42 ------------ .../generate-client/rebilly.harness.ts | 41 ------------ vitest.config.ts | 7 -- 15 files changed, 71 insertions(+), 171 deletions(-) delete mode 100644 .github/workflows/harness.yaml rename tests/{harness => e2e/generate-client}/.gitignore (100%) rename tests/{harness/generate-client/helpers.ts => e2e/generate-client/large-descriptions.test.ts} (59%) delete mode 100644 tests/harness/generate-client/github.harness.ts delete mode 100644 tests/harness/generate-client/rebilly.harness.ts diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index 7506931594..b9f53acd46 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -3,6 +3,6 @@ '@redocly/cli': minor --- -Added agent-friendly client generation: built-in `python`, `go`, `php`, and `cli` generators, a language-neutral authoring toolkit with a per-generator `AGENTS.md` skill, `eject-generator` and `architect-generator` commands, `x-codeSamples` output, and a real-world verification harness — with every generator now emitting through source-text templates. +Added agent-friendly client generation: built-in `python`, `go`, `php`, and `cli` generators, a language-neutral authoring toolkit with a per-generator `AGENTS.md` skill, `eject-generator` and `architect-generator` commands, `x-codeSamples` output, and verification against large real-world descriptions — with every generator now emitting through source-text templates. **Note:** the AST exports (`ts`, `printStatements`, `schemaToTypeNode`, …) were removed from `@redocly/client-generator/generate` in favor of the text toolkit (`tsType`, `tsJsdoc`, `codeLiteral`). diff --git a/.github/workflows/harness.yaml b/.github/workflows/harness.yaml deleted file mode 100644 index cf786fcacd..0000000000 --- a/.github/workflows/harness.yaml +++ /dev/null @@ -1,48 +0,0 @@ -name: Generate-client harness - -permissions: - contents: read - -on: - pull_request: - paths: - - 'packages/client-generator/**' - - 'tests/harness/**' - - '.github/workflows/harness.yaml' - workflow_dispatch: - -env: - CI: true - REDOCLY_TELEMETRY: off - -jobs: - run-harness: - # Only run if PR is from the same repository (not a fork) - if: ${{ github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 - with: - node-version: 24 - cache: npm - - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 - with: - go-version: stable - cache: false - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 - with: - python-version: '3.12' - - name: Install httpx (the Python import bar needs it) - run: pip install httpx - - name: Cache the pinned GitHub REST description - uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 - with: - path: tests/harness/.cache - key: harness-github-description-${{ hashFiles('tests/harness/generate-client/helpers.ts') }} - - name: Install dependencies - run: npm ci - - name: Compile - run: npm run compile - - name: Run harness - run: npm run harness diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index fbf5de851e..7f4218f2a8 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -40,22 +40,33 @@ jobs: # The e2e suite is split across shards so no single runner carries the whole set. # Running all suites in one step was cancelled mid-run by the Actions service once the # generate-client suites grew past ~28 (a healthy runner, no resource exhaustion); - # each shard stays well under that. + # each shard stays well under that. Three shards absorb the large-descriptions suite + # (compile bars over big real-world descriptions — the heaviest single file). runs-on: ubuntu-latest strategy: fail-fast: false matrix: - shard: [1, 2] + shard: [1, 2, 3] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 24 cache: npm + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: '3.12' + - name: Install httpx (the large-descriptions Python import bar needs it) + run: pip install httpx + - name: Cache the pinned GitHub REST description + uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 + with: + path: tests/e2e/generate-client/.cache + key: large-descriptions-${{ hashFiles('tests/e2e/generate-client/large-descriptions.test.ts') }} - name: Install dependencies run: npm ci - - name: E2E Tests (shard ${{ matrix.shard }}/2) - run: npm run e2e -- --shard=${{ matrix.shard }}/2 + - name: E2E Tests (shard ${{ matrix.shard }}/3) + run: npm run e2e -- --shard=${{ matrix.shard }}/3 examples: # The examples gitignore their generated clients (only zero-install-quickstart commits diff --git a/package.json b/package.json index ca3b3a1769..9edaef6c7b 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,6 @@ "unit": "VITEST_SUITE=unit vitest run", "e2e": "VITEST_SUITE=e2e vitest run", "smoke:rebilly": "VITEST_SUITE=smoke-rebilly vitest run", - "harness": "VITEST_SUITE=harness vitest run", "format": "oxfmt .", "format:check": "oxfmt --check .", "lint": "oxlint ./packages", 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 289a637f5b..5bf7e73518 100644 --- a/packages/client-generator/src/generators/__tests__/generator-skills.test.ts +++ b/packages/client-generator/src/generators/__tests__/generator-skills.test.ts @@ -19,7 +19,7 @@ describe.each(['python', 'go', 'php'])('%s generator skill', (name) => { const skill = readFileSync(skillPath, 'utf-8'); expect(skill).toContain(`${name}-runtime/`); expect(skill).toContain('edit this skill first'); - expect(skill).toContain('npm run harness'); + expect(skill).toContain('large-descriptions.test.ts'); }); it('is what eject ships — the prepared asset matches the source byte-for-byte', () => { diff --git a/packages/client-generator/src/generators/go/AGENTS.md b/packages/client-generator/src/generators/go/AGENTS.md index 5977468706..a1278fafee 100644 --- a/packages/client-generator/src/generators/go/AGENTS.md +++ b/packages/client-generator/src/generators/go/AGENTS.md @@ -39,4 +39,4 @@ runtime. Go ≥ 1.21, standard library only — zero dependencies. 3. Verify: `npm run compile`, then `VITEST_SUITE=unit npx vitest run packages/client-generator/src/generators/__tests__/go.test.ts` (real `go build` + `go vet` bars), the e2e smoke (`tests/e2e/generate-client/go.test.ts`), - and `npm run harness`. + and the large-description bars (`tests/e2e/generate-client/large-descriptions.test.ts`). diff --git a/packages/client-generator/src/generators/java/AGENTS.md b/packages/client-generator/src/generators/java/AGENTS.md index 9e205adbaa..5696b30a46 100644 --- a/packages/client-generator/src/generators/java/AGENTS.md +++ b/packages/client-generator/src/generators/java/AGENTS.md @@ -59,8 +59,8 @@ switch patterns), HTTP over `java.net.http.HttpClient` — part of the JDK since / `Iterable listOrdersItems()`), SSE (`Iterator` with `Last-Event-ID` reconnect), multipart (hand-built body), `X-Redocly-Client` header. - The runtime is hand-written in `java-runtime/` and embedded at prepare time; verified - with `javac` (and the smoke against the shared mock server). Harness gains a `javaBar` - (`javac` on the large real-world descriptions' output). CI runners ship a JDK. + with `javac` (and the smoke against the shared mock server). The large-description + suite gains a `javaBar` (`javac` on the big real-world outputs). CI runners ship a JDK. - Authored ONLY with the neutral toolkit — the dogfooding guard extends to `java/index.ts`. ## The modify loop (once implemented) @@ -69,4 +69,4 @@ switch patterns), HTTP over `java.net.http.HttpClient` — part of the JDK since 2. Change `index.ts` (and `java-runtime/` for runtime behavior, then `npm run prepare -w @redocly/client-generator`). 3. Verify: `npm run compile`, the generator unit suite (real `javac` bars), the e2e - smoke, and `npm run harness`. + smoke, and the large-description bars (`tests/e2e/generate-client/large-descriptions.test.ts`). diff --git a/packages/client-generator/src/generators/php/AGENTS.md b/packages/client-generator/src/generators/php/AGENTS.md index 542bedf7fc..1505a4bf25 100644 --- a/packages/client-generator/src/generators/php/AGENTS.md +++ b/packages/client-generator/src/generators/php/AGENTS.md @@ -69,4 +69,4 @@ $idempotencyKey` on mutating methods. 3. Verify: `npm run compile`, then `VITEST_SUITE=unit npx vitest run packages/client-generator/src/generators/__tests__/php.test.ts` (real `php -l` + `require` bars), the e2e smoke (`tests/e2e/generate-client/php.test.ts`), - and `npm run harness`. + and the large-description bars (`tests/e2e/generate-client/large-descriptions.test.ts`). diff --git a/packages/client-generator/src/generators/python/AGENTS.md b/packages/client-generator/src/generators/python/AGENTS.md index 35b505a0cc..3630e3d9f3 100644 --- a/packages/client-generator/src/generators/python/AGENTS.md +++ b/packages/client-generator/src/generators/python/AGENTS.md @@ -42,4 +42,4 @@ One self-contained `.py`: typed dataclass models, a sync `Client` and an a 3. Verify: `npm run compile`, then `VITEST_SUITE=unit npx vitest run packages/client-generator/src/generators/__tests__/python.test.ts` (real `py_compile` bars), the e2e smoke (`tests/e2e/generate-client/python.test.ts`), - and `npm run harness` (import bars over the large real-world descriptions). + and the large-description bars (`tests/e2e/generate-client/large-descriptions.test.ts`). diff --git a/tests/harness/.gitignore b/tests/e2e/generate-client/.gitignore similarity index 100% rename from tests/harness/.gitignore rename to tests/e2e/generate-client/.gitignore diff --git a/tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md b/tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md index 542bedf7fc..1505a4bf25 100644 --- a/tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md +++ b/tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md @@ -69,4 +69,4 @@ $idempotencyKey` on mutating methods. 3. Verify: `npm run compile`, then `VITEST_SUITE=unit npx vitest run packages/client-generator/src/generators/__tests__/php.test.ts` (real `php -l` + `require` bars), the e2e smoke (`tests/e2e/generate-client/php.test.ts`), - and `npm run harness`. + and the large-description bars (`tests/e2e/generate-client/large-descriptions.test.ts`). diff --git a/tests/harness/generate-client/helpers.ts b/tests/e2e/generate-client/large-descriptions.test.ts similarity index 59% rename from tests/harness/generate-client/helpers.ts rename to tests/e2e/generate-client/large-descriptions.test.ts index 50286474fa..c61f73dfe7 100644 --- a/tests/harness/generate-client/helpers.ts +++ b/tests/e2e/generate-client/large-descriptions.test.ts @@ -1,6 +1,9 @@ -// Certification-bar helpers: generate a client from a real-world description and -// hold each language's output to a compile bar. Runs as its own vitest suite -// (`npm run harness`) and CI workflow — never inside the regular e2e job. +// Every generator's output held to a compile/import bar over two large real-world +// descriptions: the vendored one at tests/smoke/rebilly (638 operations, allOf-heavy — +// shook out the allOf pagination fix and the Go `3ds` field-export bug) and GitHub's +// REST description (~1000 operations, downloaded at a pinned SHA — shook out the +// strict-mode reserved-word and +1/-1 naming bugs). The heaviest e2e file by far; +// CI spreads it across shards like any other suite. import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; @@ -8,17 +11,18 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { generate, repoRoot, strictTypecheck } from '../../e2e/generate-client/helpers.js'; +import { generate, repoRoot, strictTypecheck } from './helpers.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); +const TIMEOUT = 300_000; /** Pinned commit of github/rest-api-description; bump deliberately. */ -export const GITHUB_DESCRIPTION_SHA = '5e28810649ba41b5483753ba74f976f83856a504'; +const GITHUB_DESCRIPTION_SHA = '5e28810649ba41b5483753ba74f976f83856a504'; -const cacheDir = join(__dirname, '../.cache'); +const cacheDir = join(__dirname, '.cache'); /** Download `api.github.com.json` at the pinned SHA once; later runs hit the cache. */ -export async function fetchGithubDescription(): Promise { +async function fetchGithubDescription(): Promise { const cached = join(cacheDir, `api.github.com-${GITHUB_DESCRIPTION_SHA.slice(0, 12)}.json`); if (existsSync(cached)) return cached; const url = `https://raw.githubusercontent.com/github/rest-api-description/${GITHUB_DESCRIPTION_SHA}/descriptions/api.github.com/api.github.com.json`; @@ -29,15 +33,15 @@ export async function fetchGithubDescription(): Promise { return cached; } -export const hasPhp = spawnSync('php', ['--version']).status === 0; -export const hasPython = spawnSync('python3', ['--version']).status === 0; -export const hasHttpx = hasPython && spawnSync('python3', ['-c', 'import httpx']).status === 0; -export const hasGo = spawnSync('go', ['version']).status === 0; +const hasPhp = spawnSync('php', ['--version']).status === 0; +const hasPython = spawnSync('python3', ['--version']).status === 0; +const hasHttpx = hasPython && spawnSync('python3', ['-c', 'import httpx']).status === 0; +const hasGo = spawnSync('go', ['version']).status === 0; /** Generate with `--generator ` (repeatable) into a fresh temp dir; returns the dir. */ -export function generateWith(generator: string | string[], description: string): string { +function generateWith(generator: string | string[], description: string): string { const generators = Array.isArray(generator) ? generator : [generator]; - const dir = mkdtempSync(join(tmpdir(), `harness-${generators.join('-')}-`)); + const dir = mkdtempSync(join(tmpdir(), `large-desc-${generators.join('-')}-`)); generate( description, join(dir, 'client.ts'), @@ -47,14 +51,14 @@ export function generateWith(generator: string | string[], description: string): } /** TS bar: the generated client passes a strict `tsc --noEmit`. */ -export function typescriptBar(description: string): void { +function typescriptBar(description: string): void { const dir = generateWith('sdk', description); writeFileSync(join(dir, 'package.json'), JSON.stringify({ type: 'module' }), 'utf-8'); strictTypecheck(dir); } /** CLI bar: the generated `.cli.ts` passes a strict, Node-typed `tsc --noEmit`. */ -export function cliBar(description: string): void { +function cliBar(description: string): void { const dir = generateWith(['sdk', 'cli'], description); writeFileSync(join(dir, 'package.json'), JSON.stringify({ type: 'module' }), 'utf-8'); writeFileSync( @@ -86,7 +90,7 @@ export function cliBar(description: string): void { * Python bar: `import client` (executes every dataclass declaration — catches * duplicate fields and bad defaults); syntax-only `py_compile` when httpx is absent. */ -export function pythonBar(description: string): void { +function pythonBar(description: string): void { const dir = generateWith('python', description); const check = hasHttpx ? spawnSync('python3', ['-c', 'import client'], { cwd: dir, encoding: 'utf-8' }) @@ -95,7 +99,7 @@ export function pythonBar(description: string): void { } /** PHP bar: the generated `.php` parses (`php -l`) and declares (`require`). */ -export function phpBar(description: string): void { +function phpBar(description: string): void { const dir = generateWith('php', description); const lint = spawnSync('php', ['-l', 'client.php'], { cwd: dir, encoding: 'utf-8' }); expect(lint.status, `${lint.stdout}\n${lint.stderr}`).toBe(0); @@ -107,11 +111,35 @@ export function phpBar(description: string): void { } /** Go bar: `go build` + `go vet` (vet catches json tags on unexported fields). */ -export function goBar(description: string): void { +function goBar(description: string): void { const dir = generateWith('go', description); - writeFileSync(join(dir, 'go.mod'), 'module harness.test\n\ngo 1.21\n', 'utf-8'); + writeFileSync(join(dir, 'go.mod'), 'module largedesc.test\n\ngo 1.21\n', 'utf-8'); const build = spawnSync('go', ['build', './...'], { cwd: dir, encoding: 'utf-8' }); expect(build.status, build.stderr).toBe(0); const vet = spawnSync('go', ['vet', './...'], { cwd: dir, encoding: 'utf-8' }); expect(vet.status, vet.stderr).toBe(0); } + +const rebilly = join(__dirname, '../../smoke/rebilly/rebilly-description.yaml'); + +describe('rebilly description', () => { + it('sdk (TypeScript) passes strict tsc', () => typescriptBar(rebilly), TIMEOUT); + it('cli passes strict Node-typed tsc', () => cliBar(rebilly), TIMEOUT); + it.skipIf(!hasPython)('python imports cleanly', () => pythonBar(rebilly), TIMEOUT); + it.skipIf(!hasGo)('go builds and vets cleanly', () => goBar(rebilly), TIMEOUT); + it.skipIf(!hasPhp)('php parses and declares cleanly', () => phpBar(rebilly), TIMEOUT); +}); + +describe('github REST description', () => { + let github: string; + + beforeAll(async () => { + github = await fetchGithubDescription(); + }, TIMEOUT); + + it('sdk (TypeScript) passes strict tsc', () => typescriptBar(github), TIMEOUT); + it('cli passes strict Node-typed tsc', () => cliBar(github), TIMEOUT); + it.skipIf(!hasPython)('python imports cleanly', () => pythonBar(github), TIMEOUT); + it.skipIf(!hasGo)('go builds and vets cleanly', () => goBar(github), TIMEOUT); + it.skipIf(!hasPhp)('php parses and declares cleanly', () => phpBar(github), TIMEOUT); +}); diff --git a/tests/harness/generate-client/github.harness.ts b/tests/harness/generate-client/github.harness.ts deleted file mode 100644 index cc789a549a..0000000000 --- a/tests/harness/generate-client/github.harness.ts +++ /dev/null @@ -1,42 +0,0 @@ -// GitHub's REST description (~1000 operations, downloaded at a pinned SHA) — the -// scale case that shook out the strict-mode reserved-word and +1/-1 naming bugs. - -import { - cliBar, - fetchGithubDescription, - goBar, - hasGo, - hasPhp, - hasPython, - phpBar, - pythonBar, - typescriptBar, -} from './helpers.js'; - -let github: string; - -beforeAll(async () => { - github = await fetchGithubDescription(); -}); - -describe('github REST description', () => { - it('sdk (TypeScript) passes strict tsc', () => { - typescriptBar(github); - }); - - it('cli passes strict Node-typed tsc', () => { - cliBar(github); - }); - - it.skipIf(!hasPython)('python imports cleanly', () => { - pythonBar(github); - }); - - it.skipIf(!hasGo)('go builds and vets cleanly', () => { - goBar(github); - }); - - it.skipIf(!hasPhp)('php parses and declares cleanly', () => { - phpBar(github); - }); -}); diff --git a/tests/harness/generate-client/rebilly.harness.ts b/tests/harness/generate-client/rebilly.harness.ts deleted file mode 100644 index 39ff6625fe..0000000000 --- a/tests/harness/generate-client/rebilly.harness.ts +++ /dev/null @@ -1,41 +0,0 @@ -// Rebilly (vendored, 638 operations, allOf-heavy) — the real-world description -// that shook out the allOf pagination fix and the Go `3ds` field-export bug. - -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { - cliBar, - goBar, - hasGo, - hasPhp, - hasPython, - phpBar, - pythonBar, - typescriptBar, -} from './helpers.js'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const rebilly = join(__dirname, '../../smoke/rebilly/rebilly-description.yaml'); - -describe('rebilly description', () => { - it('sdk (TypeScript) passes strict tsc', () => { - typescriptBar(rebilly); - }); - - it('cli passes strict Node-typed tsc', () => { - cliBar(rebilly); - }); - - it.skipIf(!hasPython)('python imports cleanly', () => { - pythonBar(rebilly); - }); - - it.skipIf(!hasGo)('go builds and vets cleanly', () => { - goBar(rebilly); - }); - - it.skipIf(!hasPhp)('php parses and declares cleanly', () => { - phpBar(rebilly); - }); -}); diff --git a/vitest.config.ts b/vitest.config.ts index 303fab4ef1..a79c631097 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -39,13 +39,6 @@ const configExtension: { [key: string]: ViteUserConfig } = { include: ['tests/smoke/rebilly/**/*.smoke.ts'], }, }), - harness: defineConfig({ - test: { - include: ['tests/harness/**/*.harness.ts'], - testTimeout: 300_000, - hookTimeout: 300_000, - }, - }), default: defineConfig({}), }; From 1e6528e9043cd2cabe4489c060313890fe7ea829 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 4 Aug 2026 14:42:55 +0300 Subject: [PATCH 068/211] fix: rewrite the ejected skill's intro and modify loop for the user's repo --- .../client-generator/eject-assets/AGENTS.md | 2 +- .../scripts/ejected-skill.mjs | 33 +++++++++++++++++++ .../scripts/generate-eject-assets.mjs | 11 ++++--- .../__tests__/generator-skills.test.ts | 18 +++++++--- .../generators/AGENTS.md | 2 +- .../ejected-generator/generators/AGENTS.md | 2 +- .../generators/php.AGENTS.md | 18 +++++----- 7 files changed, 64 insertions(+), 22 deletions(-) create mode 100644 packages/client-generator/scripts/ejected-skill.mjs diff --git a/packages/client-generator/eject-assets/AGENTS.md b/packages/client-generator/eject-assets/AGENTS.md index 1c1321abfc..37843284aa 100644 --- a/packages/client-generator/eject-assets/AGENTS.md +++ b/packages/client-generator/eject-assets/AGENTS.md @@ -56,7 +56,7 @@ discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, | `AUTHORING_HELPER_NAMES` | The list of the above (introspection). | Worked example: the built-in `python` generator -(`packages/client-generator/src/generators/python.ts` in the Redocly CLI repo) is +(`packages/client-generator/src/generators/python/index.ts` in the Redocly CLI repo) is authored with exactly this toolkit and nothing else — models via `flattenAllOf`/ `enumValues`/`discriminatorCases`, all code through `Printer`, every name through `identifierFor(..., RESERVED_WORDS.python)`. diff --git a/packages/client-generator/scripts/ejected-skill.mjs b/packages/client-generator/scripts/ejected-skill.mjs new file mode 100644 index 0000000000..33938d7cd5 --- /dev/null +++ b/packages/client-generator/scripts/ejected-skill.mjs @@ -0,0 +1,33 @@ +// The source skill speaks to development inside this repo — its intro and modify +// loop reference index.ts, the prepare script, and our vitest suites, none of which +// exist in a user's repo. The ejected copy keeps the design sections verbatim but +// rewrites those two parts for the user's world: their file is generators/.mjs +// and their loop is edit → regenerate → diff. The design bullets in between ship +// unchanged, and both anchors are structural (the first `## ` heading and the final +// `## The modify loop` section), so skills can grow without touching this transform. +export function ejectedSkill(source, name) { + const titleEnd = source.indexOf('\n\n'); + const firstHeading = source.indexOf('\n## '); + const loopHeading = source.indexOf('\n## The modify loop'); + if (titleEnd === -1 || firstHeading === -1 || loopHeading === -1) { + throw new Error(`The ${name} skill lost its title/intro/modify-loop structure.`); + } + const intro = [ + `This file is the DESIGN of your ejected \`${name}\` generator (\`generators/${name}.mjs\`):`, + '**to change the generator, edit this skill first, then make the code match it** — a diff', + `to \`generators/${name}.mjs\` that has no covering sentence here is incomplete.`, + ].join('\n'); + const modifyLoop = [ + '## The modify loop', + '', + '1. Edit this skill: state the new behavior or decision.', + `2. Make \`generators/${name}.mjs\` match it.`, + '3. Run `redocly generate-client` and inspect the `git diff` of the generated output —', + ' generated files are never hand-edited.', + '', + `Newer built-in versions merge in with \`redocly eject-generator ${name} --update\`.`, + '', + ].join('\n'); + const designSections = source.slice(firstHeading, loopHeading); + return `${source.slice(0, titleEnd)}\n\n${intro}\n${designSections}\n${modifyLoop}`; +} diff --git a/packages/client-generator/scripts/generate-eject-assets.mjs b/packages/client-generator/scripts/generate-eject-assets.mjs index e4aa2437de..1bb4805e74 100644 --- a/packages/client-generator/scripts/generate-eject-assets.mjs +++ b/packages/client-generator/scripts/generate-eject-assets.mjs @@ -1,9 +1,11 @@ import { spawnSync } from 'node:child_process'; -import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; +import { ejectedSkill } from './ejected-skill.mjs'; + // Build the ejectable generator assets: the neutral-toolkit language generators, // type-stripped to plain ESM (comments preserved) with imports rewritten to the // public entries, plus a provenance header and the `defineGenerator`-shaped @@ -52,8 +54,7 @@ for (const { name, run, sample } of EJECTABLE) { // The generator's OWN skill ships beside its code: eject drops it as // `generators/.AGENTS.md` so the agent that edits the ejected file // starts from the generator's design, not from reverse-engineering it. - copyFileSync( - join(pkgRoot, 'src', 'generators', name, 'AGENTS.md'), - join(outDir, `${name}.AGENTS.md`) - ); + // The intro and modify loop are rewritten for the user's repo on the way. + const skill = readFileSync(join(pkgRoot, 'src', 'generators', name, 'AGENTS.md'), 'utf-8'); + writeFileSync(join(outDir, `${name}.AGENTS.md`), ejectedSkill(skill, name)); } 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 5bf7e73518..0f9f4dcfc3 100644 --- a/packages/client-generator/src/generators/__tests__/generator-skills.test.ts +++ b/packages/client-generator/src/generators/__tests__/generator-skills.test.ts @@ -2,6 +2,10 @@ import { existsSync, readFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +// The prepare-time transform that rewrites the repo-facing intro and modify loop +// into their user-repo equivalents (plain .mjs, importable straight from scripts/). +import { ejectedSkill } from '../../../scripts/ejected-skill.mjs'; + // Skill-first development: every language generator lives in a folder with its own // AGENTS.md — the design the code must match (and the file eject ships to users). // A generator folder without a skill, or a skill missing its modify-loop anchors, @@ -22,10 +26,16 @@ describe.each(['python', 'go', 'php'])('%s generator skill', (name) => { expect(skill).toContain('large-descriptions.test.ts'); }); - it('is what eject ships — the prepared asset matches the source byte-for-byte', () => { - // `prepare` copies the skill into eject-assets; commit-time formatting of the - // source AFTER a prepare run would silently ship a stale copy without this pin. + it('is what eject ships — the prepared asset is the user-repo transform of the source', () => { + // `prepare` rewrites the skill for the user's repo (their file is generators/.mjs, + // their loop is regenerate + diff — not this repo's index.ts/prepare/vitest loop); + // commit-time formatting of the source AFTER a prepare run would ship a stale copy. const asset = join(generatorsDir, '../../eject-assets/generators', `${name}.AGENTS.md`); - expect(readFileSync(asset, 'utf-8')).toBe(readFileSync(skillPath, 'utf-8')); + const shipped = readFileSync(asset, 'utf-8'); + expect(shipped).toBe(ejectedSkill(readFileSync(skillPath, 'utf-8'), name)); + expect(shipped).toContain(`generators/${name}.mjs`); + expect(shipped).not.toContain('index.ts'); + expect(shipped).not.toContain('npm run prepare'); + expect(shipped).not.toContain('vitest'); }); }); diff --git a/tests/e2e/generate-client/examples/architected-generator/generators/AGENTS.md b/tests/e2e/generate-client/examples/architected-generator/generators/AGENTS.md index 01922c7b66..bea3e74dd1 100644 --- a/tests/e2e/generate-client/examples/architected-generator/generators/AGENTS.md +++ b/tests/e2e/generate-client/examples/architected-generator/generators/AGENTS.md @@ -58,7 +58,7 @@ discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, | `AUTHORING_HELPER_NAMES` | The list of the above (introspection). | Worked example: the built-in `python` generator -(`packages/client-generator/src/generators/python.ts` in the Redocly CLI repo) is +(`packages/client-generator/src/generators/python/index.ts` in the Redocly CLI repo) is authored with exactly this toolkit and nothing else — models via `flattenAllOf`/ `enumValues`/`discriminatorCases`, all code through `Printer`, every name through `identifierFor(..., RESERVED_WORDS.python)`. diff --git a/tests/e2e/generate-client/examples/ejected-generator/generators/AGENTS.md b/tests/e2e/generate-client/examples/ejected-generator/generators/AGENTS.md index 01922c7b66..bea3e74dd1 100644 --- a/tests/e2e/generate-client/examples/ejected-generator/generators/AGENTS.md +++ b/tests/e2e/generate-client/examples/ejected-generator/generators/AGENTS.md @@ -58,7 +58,7 @@ discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, | `AUTHORING_HELPER_NAMES` | The list of the above (introspection). | Worked example: the built-in `python` generator -(`packages/client-generator/src/generators/python.ts` in the Redocly CLI repo) is +(`packages/client-generator/src/generators/python/index.ts` in the Redocly CLI repo) is authored with exactly this toolkit and nothing else — models via `flattenAllOf`/ `enumValues`/`discriminatorCases`, all code through `Printer`, every name through `identifierFor(..., RESERVED_WORDS.python)`. diff --git a/tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md b/tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md index 1505a4bf25..47ef5325c8 100644 --- a/tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md +++ b/tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md @@ -1,9 +1,8 @@ # The `php` generator — its skill -This file is the generator's DESIGN. It ships to users on `redocly eject-generator php` -(as `generators/php.AGENTS.md`) and governs our own changes: **to change the generator, -edit this skill first, then make the code match it** — a diff to `index.ts` that has no -covering sentence here is incomplete. +This file is the DESIGN of your ejected `php` generator (`generators/php.mjs`): +**to change the generator, edit this skill first, then make the code match it** — a diff +to `generators/php.mjs` that has no covering sentence here is incomplete. ## What it emits @@ -64,9 +63,8 @@ $idempotencyKey` on mutating methods. ## The modify loop 1. Edit this skill: state the new behavior or decision. -2. Change `index.ts` (and `php-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` - (real `php -l` + `require` bars), the e2e smoke (`tests/e2e/generate-client/php.test.ts`), - and the large-description bars (`tests/e2e/generate-client/large-descriptions.test.ts`). +2. Make `generators/php.mjs` match it. +3. Run `redocly generate-client` and inspect the `git diff` of the generated output — + generated files are never hand-edited. + +Newer built-in versions merge in with `redocly eject-generator php --update`. From 61a9f0aaff69dd3cb580e251e50db51fbd5a5e64 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 4 Aug 2026 15:13:44 +0300 Subject: [PATCH 069/211] feat: dispatch Python discriminated unions via a decode registry and emit server URL helpers for python and go --- .gitignore | 4 +- .../__pycache__/_auth.cpython-314.pyc | Bin 4349 -> 0 bytes .../__pycache__/_decode.cpython-314.pyc | Bin 4248 -> 0 bytes .../__pycache__/_errors.cpython-314.pyc | Bin 3168 -> 0 bytes .../__pycache__/_multipart.cpython-314.pyc | Bin 1261 -> 0 bytes .../__pycache__/_paginate.cpython-314.pyc | Bin 11515 -> 0 bytes .../__pycache__/_send.cpython-314.pyc | Bin 11271 -> 0 bytes .../__pycache__/_sse.cpython-314.pyc | Bin 7583 -> 0 bytes .../__pycache__/_url.cpython-314.pyc | Bin 1046 -> 0 bytes .../python-runtime/_decode.py | 16 +++- .../scripts/ejected-skill.d.mts | 1 + .../src/emitters/python-runtime-sources.ts | 2 +- .../src/generators/__tests__/go.test.ts | 24 ++++- .../src/generators/__tests__/python.test.ts | 87 +++++++++++++++++- .../src/generators/go/AGENTS.md | 5 + .../src/generators/go/index.ts | 58 ++++++++++++ .../src/generators/python/AGENTS.md | 15 ++- .../src/generators/python/index.ts | 79 ++++++++++++++++ 18 files changed, 281 insertions(+), 10 deletions(-) delete mode 100644 packages/client-generator/python-runtime/__pycache__/_auth.cpython-314.pyc delete mode 100644 packages/client-generator/python-runtime/__pycache__/_decode.cpython-314.pyc delete mode 100644 packages/client-generator/python-runtime/__pycache__/_errors.cpython-314.pyc delete mode 100644 packages/client-generator/python-runtime/__pycache__/_multipart.cpython-314.pyc delete mode 100644 packages/client-generator/python-runtime/__pycache__/_paginate.cpython-314.pyc delete mode 100644 packages/client-generator/python-runtime/__pycache__/_send.cpython-314.pyc delete mode 100644 packages/client-generator/python-runtime/__pycache__/_sse.cpython-314.pyc delete mode 100644 packages/client-generator/python-runtime/__pycache__/_url.cpython-314.pyc create mode 100644 packages/client-generator/scripts/ejected-skill.d.mts diff --git a/.gitignore b/.gitignore index f2173085e2..f392cee068 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,6 @@ __changesets__.json **/.claude/agent-registry.json **/.claude/agent-memory-local **/.claude/first-run -**/.claude/assistant-daemon-state.json \ No newline at end of file +**/.claude/assistant-daemon-state.json +__pycache__/ + diff --git a/packages/client-generator/python-runtime/__pycache__/_auth.cpython-314.pyc b/packages/client-generator/python-runtime/__pycache__/_auth.cpython-314.pyc deleted file mode 100644 index bde65a3bfa7cda44a471a3f39c85aabdb2fc32f7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4349 zcmbVPUu+Y}8K1Si>y4e*4g~B#h&LpJnEWw+0^|Zjf&g(y;gTg70)%K|Pm(3h+RU!g zB&sS)*N0rIE2ei9xrgdU1bgg=?~v&w|6-a`kFQhiO*NK*S~>E3f)37*NsA)!TD+3 z(rqy;P+GgKA)8?f@dh6f3?XDU>><$*LnTHDle2X@LQcc!M}sAUrGtD68Z13xY1&no z1fh*BqfF+#NEW;(<}qBd9qJ;hyX6w7J6PQ#TWV3j`GP75aHdnyw78)dYFyJV1q9Q2 zTAMb-P9+vohGW>YomC@-Y5R$)8z$eMOu#SxiUz_kl)saV8#uy&BKpx}gU<@h*Ai4x zD2#4d+J1ph6#iAAK5mSYIoTpxWm}Lgf`4HFF;h&C_@o-e#4PKNk7KR3&=d$Zl0-AD zxg*97qSg7^RkR})Icw_vA_jh(QK~c zB`p@KS@Gm7`gnL$!PsSNm#-bnQOZShOW;WTHbJw!d!3BIR%vn z4rswmC%A&6Mj4jqq1RS`v9$P_=%wOPO%+^xEMyY_^w2iA;)D!^7 zJ$Rbzh6znOG=|3Uv?&Z@g3l$l6b4F~=Io)^L-fv5HWt~;?XEV-*jIH7S z#~q+f78vgAdEALQjwFd{Owa71@#zN3HezsaFmH$|*d0US%sv*xZmfv`UI&|`jqSl7 zg(`K0UdL?q7BIc*70q(4hL=`a9_(P{SNwrjJhZ=$R(f%OU-;MkkSpAV(7+vmC<&oj z3?ol57=%#)e^D4q1I4Wk_`b-spw`v%kgx<%!U~_Q1ts{f#e>dq=dU1?5W>Pn)FmuTQ$VFDs=Tjm`_|kA4PS~&p@w3E?Fn{(Ux*)kmGypn>G#2&C;cC zh2AVF$c0N}r(Ak`)#jz>a4}Z+QO{psv|TP^b2t7E%a#@Q4VJ+yg2=9|>;n2NVXN$B zJA=Es=ilguyyYqCbcUTHHahz`t_Q3hcFN`Jtpx3D>)Yh{O-5+w4tr^2-;`=lxD;%# zJzPe2+e-ucrX*Js<}Q@IH0Ezg@{U4p!X@tvyVmawRthn?1XY7iiin4+stU>8UV7ba zN@2dB3#{X#*16hxgmR0@WH|)%Gf6cTl^Vvd0`x*}lI|oinGQ%mBc!;7B_IjF1Axu! z*QG)*k=~V?m6(Bv20fElmqti@!eNYIDbF6H2*gcb5vmf?TcoRFSd+B4v~Gl?!kA<% zHZ85i*(Ql97*UCCIM#{6v}-a_{ux!4wSeWXz#@e>P^{l z(F6F|a%{nah4J|tw&xrmmYYH;9?la)3#J8F$5E@w1#DaIUA7cr z$f{mI6w?d$IMqwMpa2*O#Zka&T3Qa8oX&2L{4FY_^0#QoNlGE75~RZn6v!8&jwfSr zU}}0tOHp90RtrjXdM$tR0?eEV4m|C<_QmN(uE%$t)U6!s${g&<_Fel{hXae=tE}SA z_fz6$1>Of&dK3IK?W+L!{(?vg?f3u4QrH3x7Z)I2|5Qw0ec1V@&Ly^%Y;fRV=flp= z*P1I$19!c@^!{G_R;O(7J@@Gf?!yYmKHm-5UmXs}8OI=$w2{3uV;sezYNn7sbf!%M zY|^&^oIH~|WrBT8o34ytLf126Kf_pzWR{%!DE*OUEGHyW=#0n5RUB}@w0O!O`{BOFjC zMaRcl&643`tyqi1qu3M*WfZfv?RH$%0&dbkHBhg?OKB}CnD*6>LMXF0?GZMuo~K?C z9YR^U$xkY=BsN8g3L0kEuE+`6PoZk^jIrx9lGcw%>r)RhgJcdG08wmq`~C3GVn z{Cw61$H#8f{xxLTa z{(pFU&nx!Mw`D7uX3yokd+yKNo0+d$5VPJxOk}QY$z7W}5S$a{CV%J3diLkaE9b5* zmDlAOn$sh5;ooXm&%T!eD)-%Y-E+;?FYNqdOQz<~V)>(^k9RzgmyU!ohr2WW3xD_Y zvbXP39rQE&)3+>DMjy>id-(GI(o@;pSt6uRnJo7aygcjNt`xd7b zZ)XChR|27@fzTJ;Y@jdelUIBLPkjTK>q^!)oaS@QM;6a59?vwMpwlhEbcfLP>bHlH z=+0G1pLBoRJwKYQYMHgC#iwFr&be*2BV(&%)GttOI);WulJJ261A_O_J5-4uRJ@_k-4_{GYi)FuI1fL anVn6`p5|298v)+9Z$x;6z1>z|v;PH{wzH4` diff --git a/packages/client-generator/python-runtime/__pycache__/_decode.cpython-314.pyc b/packages/client-generator/python-runtime/__pycache__/_decode.cpython-314.pyc deleted file mode 100644 index 9b7316f8585e5bfffc7b390029031b1b39fc82a3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4248 zcma)9Z){W76~E8#`ETqzL+rQ-A$edDY9KL?bPEY|N}EIpVIc(KY@J19>=);evvheRTwK_sM3+LzHHt@}_ZjZqU`J#7>F(r?hTVo3Y2bMN!B zO&X?M$@iZ7=iPhGz32RX$9v0N4g}>ElQ_E!(qqywib)rk_0u5CqSI)Q=|w$^%G43r z)N4{rv}IKmTDF(#F{@@e$Mv#37S+;YRjoZX)z)KI?R99->_dYVADVEhjzKHPHY%45 zn*1nW|B4(IV6?J`EGN{6DkWs4ANpLEoSF=98rvaHYqq$kjzn-=(HuldU@0!iS~-!` z>7+O^CdsN2Wnd@rBZY$>3*B`Agjp0qr%kQzAQXeK8gzmgV+NT)(;z!&3KA{2uZYc> znQn&7MCM`s70%J}qja4LbvgKCX%8Y zNlJ~;2`QM2M8_j>Q3*mvl-0(#D2q6vCU7u0t&Sz+Mx2sWX;KW1#KdSKCN?FfHRs4k z{u;!Q5$uKo%i*U~Lp6(@AwT1ON~$^k6VhZ((%w4EqGYy;14GU0_uv}9ct+%j z*&!6B)1*3z;$S8j8>LqgV*Dr!l3qfnC4Z4gQ^*vCUGy?wF{EO96vYmboxiO2Xkt{20sE#V5MH*+t!zz z+Rp6GuZr8qnz|CZo0%!FEXjo!HySdhOid_d>V`{BnoG{_GS2Te^}!>M^HZjznQBm3 zqQQ?}ra{sYt}@nzEQY<{fsNFmq2enT#{n~xf|kSq@u zZN52?5t2|lyB$dkiEt8I z64W43AC&-*XEJYyirOfS0{E)J*mMk_6R4q0n3Aw45NsA2Ul-IdQ7`~ACJazq6_l|^ zQfv~2SfHt2bBv5i;zVp@GLpmuv;tL>GO!h7%>o=E$#Km*B!j+~0H($f zUePQ%&TIBl(CflDfi*`cGLg#5wmxxMALS+_MFj$qqN>JDh_YsuRB=+#tjD0mkqONq zDUz(H5jiRnSxKFVPJls}#I;sNE{FC-MRQ8ZNTH7b4UuLA){Lks23{jpC>jd`8Yf34 z#Q={9Ok;Q-FeJ~imPv8)jEFTmQG(K~#t~8DbV5EyOwh;KadBER(*ey)Ia#w8u1d3v zPth;TbV%dKODaH2sG&h9Etrg9pHWc;{0D&cEP7%=Hs>$S&!uj%m(S0qKK$7lUy&}$ zxqKOO)>S*(y=HY?sk~NmsU~aPo~x|Qq^>5?-D|w}x+fERzdFnNAGu$7Vq#rYIp6lo zXx6uP#l82b9r+rblq0t%T}Hn5{PO2HU){X<@`Y93{$=0(+sz9N7oF*Ak<9bJE^ zzF+;I<{{sIzh{_MW)Pr1(=+4_@nmTNPY zX0q;`Ppzmj_|%5n-isIJ&Rx8)Y^z-><7fN+{Y?;gYp>7TZe6Zu`35nrsxRRkSZM$A zwF9}^p0C3<#~Gy-p8USY(R0A`G4JW+xlbB>y&lV_2P%7==D(IPP`{AkksJYVl36Dt z_OcW&2>29WG2t#uKwOVh8nq0jt_7ZjcIrMyu#0A0glQGvP1yDs+QMvkN~$?OZ7?t{ zqKZIE3kfSw1kx=~X|^QU7$7QcD5V5rzZkQb)fUuz$dO<41=_zC%ykD5a zE-fL>p!eY&1LVp!l+dQTZa!+9MTzJ{o`@iC50~Cpfo_b|#*M|`wMoh`P)Ht&s%+Tf zrGy*cy2LjofDTzK}F>8%}t|IQu(Vf_G9a|99eNhVx%c{7!|;n`!Yv;>XeC z73exow_Jg4VFu{dG}BW`o`hN{&%S!}hK#ioA_x5oPkn2a{)|+@(h-xd2RJ8WTVWSoF~Y zAZu(QrQ&8XMN&N61eHP}MJEtFh`&!}NJWE~qr~yAjUah@=8yFcJgdR(m0&l(n)yym*( z%7`DF{q5O>`a63T_hj9#Wu4yxcBu?L0g$UmJ94F9zQ%ig^Y%p@z)LXxQJ^Wy2N%8b z9$*>us&mo%IUiiM1z-HOx%H#=rS^MA9`YSaM{Y>-QA#GAm%}UW9ZN@6_>N^;$6A?t z_So~Ut;oIq`D4CAAtC(9K~ERYJviv=@>l{+$YVxEQ)&v6|C)ebNPjaDB^oEfzi<6b zNQ}`mhUPakhtSEHUNk{ciiq^`HQV9IL@YHSzK+{rf?{SvwU YhP;1AhqLJLw-)#x`|KD4@Je_2FZdTT(EtDd diff --git a/packages/client-generator/python-runtime/__pycache__/_errors.cpython-314.pyc b/packages/client-generator/python-runtime/__pycache__/_errors.cpython-314.pyc deleted file mode 100644 index 47c78e4ed3710a9e8b609b965b0c14197f8db14f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3168 zcmb7GO>7&-6`t8YE`KE1QS=u((MnEK6S2fVcH1B|8V89{DM*!OqezF8UaglyaVu(< znH@$Z4~2jftpm8Jfac(1kM+%m9&+^2M+Npl85F3~qKDk%QjLS2`ra%_P-^-&z`plp z=FRMz_vU+VUK}3E5oo{tNA$A=i;%xyr`POMpq)Q}uuHBHmoAefD(E!I+OoE!3th?j zvaw_ebIB6cQbuH!Y++B6vOPmw;}T7G5n0tY`~BP)fq83!xS4YPlHS!Ss8Jh6vjd|; zWnGQuU_4*8%Gq-6qITh#bPdwp`b*s*G3*x3k>b!-*e_C<_oFBlz6j$efo`_q3qPp) zNm4YV{#w+MnIEx;@i36~+YQw6>oT+2YOpn*OS&rQ)u6kxVqI%#XA!zS_@+z*b&1ff z4GZ0+!f>?-vPLdaSDzqtyM9WTt|6>7@&hU|V3#TF*Baqf&STzw%k{&ARh;vp#^W7l zJFc*zQ;oUfJ5e0XFW$J}aF#T{aCX57e9l{8wCRW%b1EzV<%+{_ALn{pX)Ofk?XJfO zMiwlkoeLoBf<4!?mw!P>1;);jcW8~e)YV+wHCz+8{NCW@NO;ooy6HOjTVrn_e<|3j3=dS zpC>i;VY1a~m3CsjRoVXHZ-nzfJuEf+V9VcRNeMbE67!oF8(+k{)M$wsIE6PO z5pJ`Rr;eU1G+MIYdEFQ@&y!<4_bv<$p9V9M5|CZ;gqY^kV<+o~N+jc?qNdGKX!KPvP2WAoBEpGt*xvN6y3CH@oDI z_Rzvev;*9VMWnU1`XJU z=H9W<{>b0P;N?6IP|&QS3}OAC>DRk6Bc4|cc_Qjz#G)9cGoDw8130rXbPP^_AP+oW z2p(Q<3YLJ(c^S}uBb``F2+!QJuG(UySyBT4p#v;cD^rzv6GqLT4aM+E2u}Y#+ zKoCdOaI?wbS|A``7w6BrpQ}O>WE{x^kRs)iz|x$O#t#dDJbS{E$B_Rlu1Jl2LM(IoF_PcCdx-4r zcNMz_$cNtjOKMI9Jm|v`mS^FEGiN*3KsW(;qYrsnfGUZ=ceOPgsJ@u_{}ORve73Uloiy?Shjy=X)1I(&9?dP zNw`n`t0Pm?D<@ut6$7Y07@oLY*!%hZ3*Y}NbGLP`aDV;&^8R~k`-Sx*UCZVUE%I#f zp*?jl@!YKfkWUI}63~@p116nA3tt2>h)c{c90}&41d|!y^07wEx9JlLV)Zo!EQKa& z1@QG7gbEw&`~>Jgfu0lk^AI8|(Ph)s=U~<~Kr)qNfs{e1Y`N+Y0_FQu|51@~bHaA> z5TJszT$VKJqWx;h=do@BRIva$NzHGtR{*GfAnGj#s-Y8Coeg|+Y?L-s&DwCn#7US~ zXy1(5v;OagO& zsf#eD04}w7O9CR$;S2HB0?uLy+9i)hMt{@(xP5zde`IFgo=NWlW{DNZHqQg+FCf8) z^6wzQ93Y{L47>-SLf8$CNlCQ$6;vuB!Clh*R3&^FyGU>^l}dg^zBHB}<;ETuV+Z;1 z2gdlp@ad0!{Ky6j$;#O_KErH_3@s7%T|1HWAZs3R|S`B#P zsgQFEMtBAZCS8tMh~vXfX0JjWP;XULmlRHvfhR1N_T{vi{*W)hIt)*;0pw7pl>R+O z=()d=nLm-m2W0U}BY*StkKWp)U-X0{%cfH|TSo-g(Rhwd?}bMM*x@ro%O7c3I(u_{ Y@15IpZ}r#Z4uLKlTg(3qC)Bb32?zh&zyJUM diff --git a/packages/client-generator/python-runtime/__pycache__/_multipart.cpython-314.pyc b/packages/client-generator/python-runtime/__pycache__/_multipart.cpython-314.pyc deleted file mode 100644 index 9596e648ce740a805ca76a5fc13c01ef85b92425..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1261 zcmZWo&u<$=6n^9V;ba|$Kt&wL&*HY!G+`^%AW$nLq@>tELS-qAP@Py?@5I^0-d)X% z8*RkF2PA@W@r?)}!66s!95}*%kOqlFqeviC9C}L?pCJpI^Jc!8Z{E8y znzsP$H!ZxkssTJygVZul0Xe&gpa*NT@0yt&?+GAqa$rPcKeWVU85t(y?AQZ0f!+?KM(xmob5KDd0ZwhJL?nZ5j zF(wU~p+}npw=w$vB#$+ltP3q#hylyP+2| zwN;voiY^PbFD4{+6yY5%*!&#wug=dsKVJPwUDaF2Jq`N9q(8n}_a&}{p5juK_P`lCT zwuK+LOuQY%a-`81NJAS9_7ZaD(fK4cJ$M2+Qh2PIK0j66!R%r(Ji}#3gi=76iJk~m z!F%_ho(hPPl?ON?Hbfb8?KZhi-b}Uuv=S+2!G>FiUbOOHVhnC_fGw1lE!B-Qs%F#` zZ({YK6kW@d2f6eg$2s*~^;{ZJGaPs^#>=XhmCtJWB@FxceSqh9M$*0Y(G>lk$3xkW zXDp4l^-7AK_2;2xq>8LuNVr0P6=%TP1sH}ISSemp2}PvNsOyz1hPMN0_=0x0w0!Od zT)07#O5<)UD3`;ka~X4EncZ-?D+H6er_x(*`yAVQJPf4abvqF+nM_ro)VF*_G0GLL zG}iq#9;0p2SQ_`;c9(Lc?4>znOg&kSi11nJu=6;TWXjhFdT^3^xqo5zMsM+WavGbd zncmWIVe;GU{`MX_8o&Ht{PG{;Z+&C!XeYT>`}BLd|HZ+yeU!U;n7jJ(N53rn`tDJA z`LMiv{Mt;vxVwCkFYFon`o6dKo%Q28zfY8Q7I&+EO-%RC?N%S> zp>XAN6h_8&t{>>*ObK7Iq}6Dwcd_hLMys4+uVW)k5l4Q|O6roS6k_UyGTXqC$Fffp zPM*coUngfhefZFgPUv;p^aFMg7gRDQ<#DPJLjJLU6dywFPbfZs;@>d&#Kba5HvbE5 CiWp}A diff --git a/packages/client-generator/python-runtime/__pycache__/_paginate.cpython-314.pyc b/packages/client-generator/python-runtime/__pycache__/_paginate.cpython-314.pyc deleted file mode 100644 index 48d253ba01ededc7d173d2b79eee75f850dae867..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11515 zcmeHNYj7Lab>79|4G;hUlA!p6NQxvVil9VNrcAvkJ_LymF%Shx5hWTTRwN`4gm#w_ zDc3H?@efE&##C%qh$Bzgi94klcg!})RGnrzRO2L0nsh)yGT<#cc9Z^be~FPjjy3H} z&$){QK#I1KxYOy6-XYJud-uK{=X~c|Y&YfTDF_zjja%!FX-I{!9dC@j~LxK zLXO)cnA~Q;TuzWkzqDXM`!V>qMSn7h7=kn2&|DUaGt1ucTrWAkX> zNsCWGTCjO^@T4d2@;nAe8%a7J%H_b5DOIlEbNg3sHiGOiZWxUnS~f$=mQ>4y@SF?J z*3|PBkD`)t=6!^dbkd^17Ya=ZJ|Qp};`^Oik!cUjh&k>2Olb6oz;Zrek`s09S9}4% zcP_|^+74eZNK(qqz^EW<(}lFh!q|u}D5?gh!a>%_h`O+kBn%!F1{R|Aq`WX*suFSr?%NaR!dLR!+jBZec~=iFNH6T15)j5k6(0@;1X07W;h=An74-o=5Q6t1NGtt#mzgnE5S76I z52XUUKLGbZWZ+7POppyZ)f}z}2UCTsHaf*|a3w^QFnN&;iOfZIMpOktes-G2t6W{} zfWX3#nBsC{)2jr3~@eh!gr9RY{<|!JG`1>k80k`OXO6q9{!&3UhUqwu|EkdRb4y$E1Mn0_Vojz&AygTS!*8@P)6Hq zKeq*@uuhTTiH2RKAJ&q|eW4P?{+0>t7nv;Y2lPh8Y}xwgqh*A zz8X}t(eka6^iw(IqSCip>18|$qV4`Ib$he>g03)tCS$VOPuErzQ<0BNH*FtIiLB4$IIQ#PSU78-BXu5a#v+jRa2H7M>M|O3m>?_69!_h?CPr>A zy$f(CwR&X|m^F1T?dYk!L(k54}cuCo7s&&yXoGs0*ul-i4KAJVsI$&S18F z9a74puu&f61z6!}dREb^(xaAy_3ODE)sduZBZ4^3IN%QTFa zmIQe<}6kY3V@xz-oa0jR)aoXklOGC zulQzohmYd|mszmGDqffYgscYJ$OC9r5jEW}YR7&2$OLrJEgC}Xv@jyQ=ZayTkMcp+ z%Y&5i9X`SFsK^0*11xv=U`$*fH0GG(9HFU+b1e7BomIsj@Jq$~m(h+tO-2Y17GOJ0 zTY|taM>09z1nWrkAzTTd)ei?2OuA>yJ*0spglC2Ts!9IMgWt9!Qo*=CNvEwhQK6I zdmfDLIRO2lHay9bGa#D69D6x1ImP3sp#CAOuZN}szF^=g>*vvMS4(&YF%Jn6@X=@H z!5xQE#KJ1T1bhT$@qwDk$(^%*qi?or)nJ}e-!#q{qZM&O>1=0GW1Q38(-bCi^KTa3 zD2!G`ue`A%o?9DH0~A^B6dHULPR3A52u9#ilKep8Q?$!H8zZ!2o9UJk-oBi)w3RbOK=d0e<-qtRb zE-B)*9dT=Cq$6pzUVk<^I`6p^z#=w)V{dl6WqXT_RW>JVEi1N`)dEMXvh_~hor(K< zdSk79vA(g`*70~jAZ7_b?XN$F1-4#{7X)LLU^2h(dUsN1nydbL`KqmYUWnUvCA7O% zw7WjE$2Z z{n$w57TtO}X0HC2qG2NT^6Tyg45a>(e+tgS3!TO82Kt>++HF(5Q|5&9kF|O3eCo#~ zhg#v`-F%ySJM(U>0@HOgrnfVYUI#Y;W=U|<0SN$GpXWC#T%4lx!Hm9eo|l2c1<1*I`3|I^gEYb-?9Stnam_3ZPKB&H%bC z8v?YzfC~za-NtY>?fPZ4kOo+p(#Vp>J1Y)|O0;86wW#30)WR@C6|gI#Z1yEb5OO z?my<~>S#aS1xN1$DF{v@H^ZI6Gy|>kxN+z#rc@@;_9NIlHv(_SY5N)^W~mPhx!?@0 z=Gtx+ezP!Izo33E*A-E%TC6v<-_*voG%Tv(mfaE62j;x@jd`mk%gcf5fmmV7;?&ND)_@AEo)~WeR-yTksx|T~_@zR~|*>)yv zg*V%7v_&tyvVX<6buEXoHm;c{qa|~!W&Z__3^~?tl3;|Nd-;&GQWMfjLoT3Xuy#Tv z?kI{T6bS#g-6)>qsXYUQ(sWWj?OdZkM8_Xsv=AcE65K?1l7^gppU^d_#G6?L2a0EQDI$%6dK0eBf+bcdmlijdqa@lfi8QRqR7gAcjz|huO4SR^m?|_ z6*gvpY2$F3pq9rg>lQz(c?s1(d5y9~Xr1(=Hd>Rlt;&*@i#}AaTI|X#bOtVlRM8 zK%l{W6?mO19Kb{YzXUm97N}UKNkWEr%SDZ3ka&U)Dv`Y|>T`(I%^FBZk$Y$~WB$$G_8;sCsI->Z$jNTCW`?(C;3= z%Hrz##oOn!t9v?@+`sTA_8ec?b39hrGJg`p!qjqQOYE!XVy7<58R8blhn2O_4v5zb zRPmk%M#}1(>x|{rMteY9SARyAO9 zh*eZH=tK}mX+eaP7DR;7!Wbznh##l4aD0_WY5ff(Hfqcv8nh#f9s}IugJ|h9dU0sy zrgS*yBKh9R@j6;unIUcD_wxNt*?YLjgGwMvnDt~y&mrknBiRgYwVvE&<<*)l-{?4T zS!;O5GsKN~Mkbi+9p~X*3-DpX-#~mDa_jq@D&nz7E?X5$F6)}9Hu_uWQjxkmS{LFA zWjjEU-*gFHk^R71jF;ozEk)7h`OEKF8Y0S7OZmL=FUJ3DeBsI+MZCH#ZrKZ_9T;5p z)*$e}_Y4(Ddr87rvSKWGsG)LQYv4SUe#Rps^1}XhJAGHBLaC?Q^VN3^21tMTBbMT1 z$z)UC!$9K^URLroiPa_!XA6vj*zc>>b>Gc?H{$h0sLiAIil_7-jm#^`q~893A&K%G zsl-#qq{N3R`@s)_DN44B_7mb4=tO-M9!w)0LZ5;c=1tSOo9@dWW%ecO z^Ot543beJK`)8s6mLtR)8=+HcjR38b$rEza??*Ko06i!&43gQU33qu_V1|;Y;e}h8QM5S%jbcQVe?qYtiXKOS zzIr_)4`h4D?--l%aelsm@VtcK<-miY`Z+kaR5u5njpVv;@X4Rz_M^bwB%kISrbbbu zQ480BkDVZ#d0)&{b6r@HxFP7aaDEglit{LllalsNxG~Jb)4;h=96?bF!ntK5fI`Ar zVgm3Ua6Kr<@Om+Yt_e4eB8Vb@;sT0`AVl>93vQL4LwALnKoLUGkK!1L0Tf?B0XLcA zJRtZjAU1N=z`;mC!fpom!H>+sb624Ji&(J~?kHiewg2<%bye*`alEP}QPz?ouwVK= zNML`XJZZCo$5mao(EObX@Ph^5pet(@3~?aFOPjCt6XyM;qpSJl^V)=?aoN$Ba5OJF znisus$KH7Uz6kh&`PZLE?znkxqPcUqxpSp?;8i}_nke4BT)aI|>{>2%-Lp5wyeA{> zRqK{$ZKCKv4F0MQEEUA9ZsMWM7sd+e?wRWdRqcxxoQqk`CGABKcXC@zqueth9P!70vT* z5DV;bMRV+wFE%?pt#XF*dv7-8UFNl|VzBGwnRU302 zUFZa{ShaZN2RoJumz_uBRXvHaof2pJ~tx`hz%eyul4}*N7 z_xw}5@bw_jFVWD&Ybc0Uf-yQJ7^6=DF>y&Ce%vEL{~0_I`r%I?@kRc#i82ERnDS@| z)-U$I-qGCvD1$tHLqfm-)tHD#}uuUa^Nub|~qN~tP-fFk05vitlm zg{5=KWI@qu`iBg)rEE-UZ!}3ZZA>4%0TL7BjnvRT2PkI zka4$+fwYtEcNU5Ik&*LL@Z(DSeIpnIiaQBHR0}gK({bwb2UN>4)dHUtjd^o} z(aI`dD`-vKUv-xQq{3hy?VH!SpeNvQ9J3nm% z=IDWy%H0CrYWew&==2*6u`SMpJ&PyqG%YcAT7NKde|uMKOV@o%_iWCm>Vx3Af7-2p L>q71n8TEeyFTeHf diff --git a/packages/client-generator/python-runtime/__pycache__/_send.cpython-314.pyc b/packages/client-generator/python-runtime/__pycache__/_send.cpython-314.pyc deleted file mode 100644 index e508909b4133e7a95e3e86a4205e29ef690a0384..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11271 zcmdT~Yit`=cD}>+OMHu@M9GpYOR{XqbYdrV{E(wqvL)+bdNiV6vKX2iNwg)A8B(_8 z1`7eZ*xE^pC}~7TfZ7V#$bnlj}jGcl0uru~sW1r0lm>t8+R z4u_%@`H|;tFF0q;ow?6*&zyVC$GhHKVjz&Ls@RE+)r5SG89CV{hPm_Kz)X=r!ZMwt zgGn+~$SXP(Nd>(tlS+D5B~|pUPO9Oq?9_BN^ZcL%yCdX&hu&Ri~+= zBw12LHWJn~#w5*fcf;Mns@;UuxXGwBX=Sy*>nLwy^{j$5z-5HX1Xl@MX1FYHS>dv= z3ZI^}Lk$Pk7*!1JQq>fa0QNj8BflBBC;p6h!R^ABylo;;b3R zaTFdkRRcLD>I6O!hk^2EJ@TlIjwF-uGaK0ai({fj;FA-=xTrcFi;Z5yDQbpd!dNJo zz=5t75BvHP(6(vCOj;axC3=a7(`b{C?+c2UjT#R^~-`U6d`1Do*%%{G(87& zJJXQQ4Qp^Qst8L048Dmg1IofXx=<`rO9Bd3v9GumOa*HN6n<5{p`zOWv?(QQ5tdYW z-_jZ3$KtUhKOUZJ?&2r+pk}Ej;&EOO9l_4F_8$L!uDzEF_Ox>y``h|NV**rgA`uM7 zBD`n|a%^jFUndsm<661?zDAW`gtmneBxWF@DiIpu6F8ds`ub|YfzNEg2tO2>7)=I2 z1%%09Y+R^>=X4GB05e6hM%!yU&hL1&WwCPOf^lQU=ug3KbNdzd!e;+e+h<10Cl+Vc z=$KBXjNXi~BW3KEIg_c{v{1EaaZATSRYywIA(TQ>--qH0XqiMc9qaufO@|Ngn5YdU zlOV#RsEm#$MWZyK&`^>W&XyEZAHNB~LR%RcjfIke4fsYzK*J_!I~5GoL`qiS3m*_v zM;+4->WFm4oTK+k4(Wb*r*u#CACU@3b1v3KTyv_8Eue*s`&@QXodPp`joU+FiP!1{r%plW5ZNJn-6Pl-d zYAWhEu6ju8V5a1eu$p~L#Dnu%(sh4Pdvd!qux|uZa;;jzfq|xdJQ5odwG-n}w0%&p3JMA}NNTZ)>Ub!@i%KDMI)SplZz%r} znerbG9gBdbiz>}DFjM3+tv#nu>TS0io*8f2QI|5+-PI72ZMyVA+3RI9JLhWCwhd{c zZ>lY8cVB3Hy)jkMl(sitaxdDqo?kN`xg5H(Hx)Q|t?TBVLpNIvPwmdut$X+I+lS}Q z%sbO{EtmOpUDsbc`&!E@Ei*MUeQ9G&O2wrPa6s?f)e~#^UBc*XpO~yUCEV{PP~BfK zykI83HXC24Q2yG>0HsDk)WkzVXe=S>Pb6aF!Q-*Wq^J$Y#=#=Nz7`23LnzHrJ|Sw+ zgu{L&TB1ufD|nKhq#}FZJ4ZoKo#&`99=XA(q%97%IX&#@M%d3wU@y18WrfQImz{O6 zPPmuCI~VKrS#VdDY=TDGan72gy=^z$e?$$8e<(UER73BgA{G~w6T+xqg4~jEyuY+B zKDc!r6P|*CE51g1b4+*|+X3w)!X_lRkqFNqc^1fhOTp$)Lvu{ng0HAjq5ZjUesC9l ztRZTIN{L?qK|^$uVLZ1f&6+$pC1!tbD$l8r$!Un*ozqfQM+_x7J!MU##F{IiteKeW za~8^4Nl9JKMp-+tI&u!mI*HkqE2XT97|l61Wj(}X$(2#IoR}+fUdmPwS6Qx-va5(` zO|FWv)kNdGU4yJ-2vJO!(7d4hsZzYBB_!mXB0kt!SK69cA{(@#?HFpJIq;F)3|F%9 z^SC)+-3e>FG0lnNuz$TBSPzp>n zjKo$`aF2Q_2Vk|NG*H+i{J56O(o!9{)qs^%*Fz~*6Hv0+$IMR&rF8)?0s)0j9rHe9 z^$c>`UtfU@ByJDbtz=IhIBSjp{Mcv;IKZedoAa_Qs<#g3T2(AR$ADF?^)>l{rvQ?1 ztf;R>c~m{%xyPLWr!3E5D)p`|LITbj(&v@SRgtcKLiDg<3^D`gko>_&;2tl1NWBudbkUq)Evb9z-NdkF2y=9C*C2_zy(HiUCjgzse8OiO@uLT~EcJO= zZBzNuL&$5E<(0*GP7}}#NzyY6fkz5b)DM9sRe(HLU2#d29;f5CBciVQHL|IMP=M8K{-V!zosr zD>x?d`VG_-%36xPH@cz-cF3)BZdooYGomQcZ8Vh25=f!-k17R47ksZHq~Lygs2|fy z9UUpcq|Ob7jlvEj7H`Ad?gQ4icM%}uZTuOW|wHBGL_(-`%(bhOatJHirzgi2bF(+A1A8)t=#U8MmL?A zfTmP5L=(|*z!S#9JoS!69n4@fbbOQ-RTC4?xEkqZQ466GMA-tw1AsCKtFbODp0tW; z>d=bz{=W9$?rsW2w6*VP?eFG9tBio13G);h6IB>Pi0Z^B&&O%VBw%nT1!rasf-#td zAR%U`6{i4xf;bMw8B9!s!+at^C3LYuKyOIchGaXEg7z&*Q1a7PaM71Ma?v!#3&VUw z3gko^KQzRLlhIRrP!4s>`2u;EO;lpI(*`|?im}r|J2IxR(3xN!gGdN_aWph~#1#@? zmnS%p_-H81M}!{8CeZRi7+JrjkgAK*^OAb0P^km~%F+nn=b+D^N4eEx4adx$diU(x zXD>CR8@6Q{o=-JAKYu(`+jp7&@Z{x_R|H_Ey0f+GZqz>emUe#62m9XNcX=|s<3LJP znyouL=lWIo#qvv?>Bg2!V_T}R?egkW-Qg<@*PE|2r}_^9Kh>MlkyXt%Dz~2Z%@2KW z;{6k;&LioTqf@(ouQN{XT+~%(P4?Hi&v(Dtn_A^tH2Jco%Gtq1(xkOfH zovy#o^m@}@ZOH0u(=Az@d0L;!BF_JmJ-#Ea}+dpu<@49SSe6A;T zGIq}JnX&Zzwogp1>C?Y6Ro&JSOBsyAS^8#TW=F=me!;tbuJh7MY43As_qLmkZ6HpI zH)E+Uw?ARqP zUA`?-emYftdhyWlRG42JKAAZ*dgIXOyzs%P_fK6OTinH^gj46bKC_gc|FciL^|NO# zxnZW$x~#*y=xE4RHN4Sr+ejP@IV-W1WvsREOId4i=uL~(`cJH$na0_Jb0_B4rpk6_ ztZg@}ZU1SqzP9g`eQ&-z+kL5FerslX*TVL$%=Y~Y+xMsZ$I{ye)9&D+=|$1x%v#*% zEcfniBG%H3rEbAecbBO2!%WuVnyFZ{)LmSAsdN7H6+?D}2ua_!-{!!o&Y9-IwB*dXLF*zQV020Y?R>M4stS5$aMSI~+%;Ho9 z@E6IWK@VXU7I_fp5T3_pHXx6JS^pf~7tjrkq8muTqU4mwP;>*CJi0+y3EiNqgluli4hJ@5yGMQe}iynD8iuy zghTfjghK@ghaM0P!y^%n0+#Ugm3aih0e^zZK5bFG?+@WH$!iJ`t>Sw}I7+@H!eN$2 zRWxUa8VU%9M2ErI;e{ujMzL?UHspFm^!i zC0)}3fRBI00WR`)Pk@U6!uB0-5ycbWBK!Wt>e)xGzO68^9(o9KWg;YRrC z5V~L0V^KT|QXLzr#_(=!WAz`V-mFf9#`tOqf%x)qr?2QAHYhn3g5wk6#D*t7X7MjZ8fOau;6PyecgA8^)(NP^#Pw4d~5LgjrCVGL9D;Z{8c{SU-yFs{N^VP z`0ZsEn%`)C$22!EzvXgArj1)@<1%fB7TOM_4iBZS*4-Q+bV9gS3w6S*y8LuSR~o z3d&sb)dK%pM-|ddWk9c&V9D!GG5a@bu0M?>uW!MU*S9N>--&z+{-Z1^_qKDQ(%;XC zs*d*7Hc`{o-rdf%i`xBuu5*8HUyBAxeKErT`3EF-k^B#muaMjW(&!Wo!QjvYoHE2? zeqh#!a2g5znI!N?&=QN9*rnEs2b`Gc!9MI55@=@hYjslgM8Uz+g+NQ!`3RijIFon}o zI80$HEgYt>y9$RXs@D__Q&dzJ4pTI&|4KSc@v?%V4pL8)wG!-!vUY+!QPxSYC(61A g_C#3^!Ja5vPOvA+RuJrova1O8gzRmClnU+t05)Z|C;$Ke diff --git a/packages/client-generator/python-runtime/__pycache__/_sse.cpython-314.pyc b/packages/client-generator/python-runtime/__pycache__/_sse.cpython-314.pyc deleted file mode 100644 index 8ea0258c5af6ba4128ae541b67fa96588cd8542d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7583 zcmeHMYit`=cD_Rn$sva?nbiBiktNxtC7FK5??kaEOVPv1n31w;+00lRNwg`FnHj~F zU=+f(Xl)}!l&X@$GKS-R{vm z?Ox5B@5`t8M4RS6<0vMzM6{O@(XrQJj=wiAk5(jlMGi`wr6lD~G1?~4C%Pcb=hFd5 zyCLn#r-P!Uj)eV>uzi>@cqyIEXp)x9q}3isI}?&74W=Yj4ciTCOM2Y!w5a3h!46GU zBrT&D+;J(DlFp@MgKbR?Y6iDjax#nENT~^n%Qcvnk49EA!93{#W|EvC8Y2?WQ?X{P z2mPI4Mavc_5v`g{WFckCr|dLk&!-%s1Ijp>a%!Atg?YFP-ziynOIA+FX)XE|5Cs>= zU~w8f6P|)dr&UckW7uS@VOWxhH6_Wk1|^EDDdQiLu+!k;@w7B5$K!@89v{sl#!|?; zfFf8L>2h9;v| zjxMkvy{u~Ty+hVeAeB^h+Zmh4V@zoYjzRzP>QJmJo+^B zZYprAaV_cN3v;#(0{i-D*9ev%@o?|p0){#$7L|^?=gtM95NW;&?DR~Jv}kg z(%|gYm>c%Inn{OQg=VieMPc4>C~`I>4a$a1&8Cu?;R4g7XzInJHexulz$Vd-8+IuR zb|+ytl4>FeX2`IDsWjO0nPgfC!+3@*m5~xEwJl+fQiURb76_HdaG?c`qk&Wnj@sRL zGGQ7+rz%|p`zfaq28$I8o0(wZhV3mWH3qIllP_shdKIUzWs9&H%){>kIaL{LTzp6Y zbFCbPdi4Bix(AGprH%TKr6Y+dk&bN*fP{$01~ zZ?g;jqdEWadH?Y{tq=VjS6H3*P3?aB&~)&F(2dYcAXnaezr6V~cTlgW`C!M59W#N2 zisq}nE4HZyo%iSX%6YzW`go2P?(>2kET7)|!;ULaJy>?d3B^+<7WqoZy%yCyo2G`| zj_dA{oV$A7T|F&7bk}2M;_WkfY1OrM zDSCx@RzuvypZNkH-#Z7d9-R8_0xwLq>i!@QhkvSV(NQt2-BjM6Sn>tlnYcDFUBBQ9 zU168_iks}Mkww01j_oq-9&Vt#%^`?);H)rDnt+-7db18jU9^i1krSPwD`JD)$t#u6 z#Ng3Kfsa#UY4pob0VA9~jTix)Znggry>t#(rFox1bZ%%Ng2MC zjCev$N#n3NV4G=52x?E17r@5pukjO#0M*tw3F;Oo!Tq7O072idhdBwp0jXZJbJ>c_ z=e|wLHe{CV#2;LCP?jSlmCH`bx`;jWm`B#sUjBSShlHRdsw2eq!~(gSNeIROPrY``!=`Sw4RZH`#505^h5SNP=Z|ypSJrguywAT-FJ7 zzriMhUz-HK=IVd1X~h;p+Btv#g@!)oL5TA<8@yhh^LZ9{wf~fQ5Dsfu!Iy_Ct^E=UHMISmL~lqhCD%dFcY_LxnkYZ$Y2Gcjw{Gl0&9hF@9w#j>#~Q zAt{*xVd$uM`x#S(k1dEkXtBW+rla6mJofx@2`kpsg_3=iC+wRi#i7;cqbRpa!oX>h2XG0=hG$Zr6p6C2sjjSUUSieVcZ z8B1TFu*$Ggv;%PIoTSPs0=g{_Hew=xx6?%kJ~_`qsTuC+FSu%ND^|@>x;Ubbqd>VX>%TrVOg)0$b+; zTW9tx26pJds_WhFcK_hyQbj0N5xHLx(JKz#-j+MmGk>UO?%>I}vR+--mJ@c)3p@4N z@KXm{S+Y#n;*w<-sj8mh^zzCnyI$RptB%Z9M;=!1ngXO$@xzl-ZA%r~XNDeD?A5n4 zeU$o9YH`o;JFhNmIdOxZwl3A~ycJleeQDaRSJp08HtV5=55{kd-w*B8ckchW^QX?6 z9?ZIYieZ(y{7&>x9%@_wvTMu0yBsI`J#rep6)09;+(I3-dF#WFwRZ7yJ!hA z{Ppwx`VZyYmLv09j?DRvJ|z}s$!`Kxy1)3bl?1Dot&si_z=X4Cs^zaeS2#V?2$EgB zd^-+kjB~%^y5{=9ew5>8?T4pkg13rpUAj~Iv&#>5wLS<(?`R7RT?>Kk9M|((H3h5r zz2J!#nd|;&8TqK?VAN*2+fWbmm)@2#h(6gGM0%Km;#sQ&YR=mDXt8tF&tguH!JJ|% z@?{6wYOME)IHVN}(n_pt z?ql<)@De^db$tcjB79d{uf_f_dkzi%u-u6F_BI&8!uqshYXJiFec2N5$j3yEH2$>-OegJyF z)o7r4_Ai6iFMB6G+;TfO#PllPO)ubpr_plpcr-n}k42 z!5gXaDiR94T+#~mQchv+bKsT|!-Ch4^Z_v(RIFr1foDUad{0OV_7j?)|s zan1#(M@{~J6mX9BkHR@2z&WLxZ@!Q#ZG`{D(#Dxf8=y|bW_;~gsMwwhZl4Qo|5k{n zt}$1)f4*-2V%>pf#8nhtMl#*-r^Y^i*Y!9|%9{Q)xc#vo2Ar!quw3%ZFz9b74B7*Y zOvRXVp|tUykUwStdXKPB=N`*IoqIME>mGODgxC5j zhC}LNkn-5WuRN6Zw$wu6(+JbPoBebr2Yi@O4#5<|RvN{IfrqgCVz?N8mKqv^pH1*r z54`Uy=aJxxta*(3fiw7(-|=Ks0gD3n3T-?Kmq_~in1b&>iUSGWbQLEOM0)1Qre_$R zWy~uR-X`c#qd&$_U}QeRa!1VH$Bro(r~*fjsCb5#tqjBbrM>@VB?#~0|bHooA%)&pju#5n=14ejah2%0OpLy}en`cMxwe4AoK{#gOI6C%! E11v@>K>z>% diff --git a/packages/client-generator/python-runtime/__pycache__/_url.cpython-314.pyc b/packages/client-generator/python-runtime/__pycache__/_url.cpython-314.pyc deleted file mode 100644 index 542a206a3d5e1bb4bd35266b5f6406ef75ebf297..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1046 zcmZ8f%}Z2K6uFGA>(AV^XAz@9Y(nZtADI`i_)d$0TMC{q-( zXi@E2<JMrr}ZgA7=6UnlEl`N#On}Ewvi^8 zrazb_L#65Xejps-27Z)LqkooZBUv)9oO?z{ z0p5DZEn1>BwG~^1quIJ`*ovja)v#}wh!|gHJXhhKR3pKq7CNFNP2{=I;Z8Yv)Q+|9 zfpH`St=yLeXQIOWci5}381S+qB5bB#-nH&PzQ`&&JSwr}XsKGYRsz0c6&E@J-VYwXdgla8t zqQfsA5RDG+AzMxF;v*Lpg(?Lz>`!&Ht4Q^b?o0HH>1(@@PSGmW_(xe+p6 zmpjPhc|ulTa(p7I&K%EG_pnJ;8#xwf)8pEl-Z0HIHKeRQUayEKovm-vc>NC5YZvja zy@2Zs_WK{%S|d8E9ykc}^oc~6w8&Xxl3MVnyMmP?*~D4sIeEqn$jEWK0aw7- z4KoJELfk~w8XFc}&tnCt`A(ThecACUEW)^ffq_&i#XHccWufAF1;mT@fgZz?1))ZE z+B$2KJIVg<$-&L!;J4(^3-h_Q)0uqT`>JzV6Y zoi|<@dn!3G{L}3IZuV@NJ*%@@=E=3mZF6Wt8{%D%`evqCCUUvO3alfp1>zwMQ4L+c z$Td(n&Re!ZPoBFl2I~yl8UGhMN-6zo5Sso? OdVY}J0|OQ;4*LhrNath# diff --git a/packages/client-generator/python-runtime/_decode.py b/packages/client-generator/python-runtime/_decode.py index 5f327df531..54880c105b 100644 --- a/packages/client-generator/python-runtime/_decode.py +++ b/packages/client-generator/python-runtime/_decode.py @@ -8,7 +8,12 @@ import dataclasses import typing from enum import Enum -from typing import Any, get_args, get_origin, get_type_hints +from typing import Any, Dict, Tuple, get_args, get_origin, get_type_hints + +# Discriminated unions: resolved Union annotation -> (wire property, {value: class}). +# The generated module registers its unions here; decode() dispatches through it +# before falling back to trying members in order. +DISCRIMINATORS: Dict[Any, Tuple[str, Dict[str, Any]]] = {} def decode(type_: Any, data: Any): @@ -18,6 +23,15 @@ def decode(type_: Any, data: Any): return data origin = get_origin(type_) if origin is typing.Union: + discriminator = DISCRIMINATORS.get(type_) + if discriminator is not None and isinstance(data, dict): + wire_property, mapping = discriminator + target = mapping.get(data.get(wire_property)) + if target is not None: + try: + return decode(target, data) + except (TypeError, ValueError, KeyError): + pass for member in get_args(type_): if member is type(None): continue diff --git a/packages/client-generator/scripts/ejected-skill.d.mts b/packages/client-generator/scripts/ejected-skill.d.mts new file mode 100644 index 0000000000..c8f75d743a --- /dev/null +++ b/packages/client-generator/scripts/ejected-skill.d.mts @@ -0,0 +1 @@ +export function ejectedSkill(source: string, name: string): string; diff --git a/packages/client-generator/src/emitters/python-runtime-sources.ts b/packages/client-generator/src/emitters/python-runtime-sources.ts index 33dea9385d..c9fd021ef2 100644 --- a/packages/client-generator/src/emitters/python-runtime-sources.ts +++ b/packages/client-generator/src/emitters/python-runtime-sources.ts @@ -7,7 +7,7 @@ export const PYTHON_RUNTIME_SOURCES = { '_url.py': '# URL assembly for generated Python clients — path-parameter substitution with\n# percent-encoding, mirroring the TypeScript runtime\'s url.ts semantics.\nfrom __future__ import annotations\n\nfrom typing import Any, Dict\nfrom urllib.parse import quote\n\n\ndef build_url(server_url: str, path: str, path_params: Dict[str, Any]) -> str:\n filled = path\n for name, value in path_params.items():\n filled = filled.replace("{" + name + "}", quote(str(value), safe=""))\n return server_url.rstrip("/") + filled\n', '_decode.py': - '# Reflective JSON <-> dataclass conversion for generated Python clients. The\n# generated models are plain dataclasses; this decoder hydrates parsed JSON into\n# them (and encode() mirrors back to wire shape), honoring each class\'s\n# `_field_map` (python name -> wire name) and typing constructs the generator\n# emits: Optional/Union, List, Dict, Enum, Literal, Any.\nfrom __future__ import annotations\n\nimport dataclasses\nimport typing\nfrom enum import Enum\nfrom typing import Any, get_args, get_origin, get_type_hints\n\n\ndef decode(type_: Any, data: Any):\n """Best-effort hydration: wire data -> the annotated Python shape. Unknown or\n mismatched shapes pass through unchanged (the server is the source of truth)."""\n if data is None or type_ is Any or type_ is None:\n return data\n origin = get_origin(type_)\n if origin is typing.Union:\n for member in get_args(type_):\n if member is type(None):\n continue\n try:\n return decode(member, data)\n except (TypeError, ValueError, KeyError):\n continue\n return data\n if origin is list:\n (item_type,) = get_args(type_) or (Any,)\n return [decode(item_type, item) for item in data]\n if origin is dict:\n args = get_args(type_)\n value_type = args[1] if len(args) == 2 else Any\n return {key: decode(value_type, value) for key, value in data.items()}\n if origin is typing.Literal:\n return data\n if isinstance(type_, type) and issubclass(type_, Enum):\n return type_(data)\n if dataclasses.is_dataclass(type_):\n hints = get_type_hints(type_)\n field_map = getattr(type_, "_field_map", {})\n kwargs = {}\n for field in dataclasses.fields(type_):\n wire = field_map.get(field.name, field.name)\n if isinstance(data, dict) and wire in data:\n kwargs[field.name] = decode(hints.get(field.name, Any), data[wire])\n return type_(**kwargs)\n return data\n\n\ndef encode(value: Any):\n """Python shape -> wire (JSON) shape; inverse of decode for request bodies."""\n if dataclasses.is_dataclass(value) and not isinstance(value, type):\n field_map = getattr(type(value), "_field_map", {})\n out = {}\n for field in dataclasses.fields(value):\n item = getattr(value, field.name)\n if item is None:\n continue\n out[field_map.get(field.name, field.name)] = encode(item)\n return out\n if isinstance(value, Enum):\n return value.value\n if isinstance(value, list):\n return [encode(item) for item in value]\n if isinstance(value, dict):\n return {key: encode(item) for key, item in value.items()}\n return value\n', + '# Reflective JSON <-> dataclass conversion for generated Python clients. The\n# generated models are plain dataclasses; this decoder hydrates parsed JSON into\n# them (and encode() mirrors back to wire shape), honoring each class\'s\n# `_field_map` (python name -> wire name) and typing constructs the generator\n# emits: Optional/Union, List, Dict, Enum, Literal, Any.\nfrom __future__ import annotations\n\nimport dataclasses\nimport typing\nfrom enum import Enum\nfrom typing import Any, Dict, Tuple, get_args, get_origin, get_type_hints\n\n# Discriminated unions: resolved Union annotation -> (wire property, {value: class}).\n# The generated module registers its unions here; decode() dispatches through it\n# before falling back to trying members in order.\nDISCRIMINATORS: Dict[Any, Tuple[str, Dict[str, Any]]] = {}\n\n\ndef decode(type_: Any, data: Any):\n """Best-effort hydration: wire data -> the annotated Python shape. Unknown or\n mismatched shapes pass through unchanged (the server is the source of truth)."""\n if data is None or type_ is Any or type_ is None:\n return data\n origin = get_origin(type_)\n if origin is typing.Union:\n discriminator = DISCRIMINATORS.get(type_)\n if discriminator is not None and isinstance(data, dict):\n wire_property, mapping = discriminator\n target = mapping.get(data.get(wire_property))\n if target is not None:\n try:\n return decode(target, data)\n except (TypeError, ValueError, KeyError):\n pass\n for member in get_args(type_):\n if member is type(None):\n continue\n try:\n return decode(member, data)\n except (TypeError, ValueError, KeyError):\n continue\n return data\n if origin is list:\n (item_type,) = get_args(type_) or (Any,)\n return [decode(item_type, item) for item in data]\n if origin is dict:\n args = get_args(type_)\n value_type = args[1] if len(args) == 2 else Any\n return {key: decode(value_type, value) for key, value in data.items()}\n if origin is typing.Literal:\n return data\n if isinstance(type_, type) and issubclass(type_, Enum):\n return type_(data)\n if dataclasses.is_dataclass(type_):\n hints = get_type_hints(type_)\n field_map = getattr(type_, "_field_map", {})\n kwargs = {}\n for field in dataclasses.fields(type_):\n wire = field_map.get(field.name, field.name)\n if isinstance(data, dict) and wire in data:\n kwargs[field.name] = decode(hints.get(field.name, Any), data[wire])\n return type_(**kwargs)\n return data\n\n\ndef encode(value: Any):\n """Python shape -> wire (JSON) shape; inverse of decode for request bodies."""\n if dataclasses.is_dataclass(value) and not isinstance(value, type):\n field_map = getattr(type(value), "_field_map", {})\n out = {}\n for field in dataclasses.fields(value):\n item = getattr(value, field.name)\n if item is None:\n continue\n out[field_map.get(field.name, field.name)] = encode(item)\n return out\n if isinstance(value, Enum):\n return value.value\n if isinstance(value, list):\n return [encode(item) for item in value]\n if isinstance(value, dict):\n return {key: encode(item) for key, item in value.items()}\n return value\n', '_send.py': '# The request core for generated Python clients — mirror of the TypeScript\n# runtime\'s send.ts: default + config + per-call headers, on_request middleware\n# BEFORE serialization (mutations are sent), the retry loop (idempotent-methods\n# default, Idempotency-Key opt-in makes POST/PATCH safe, Retry-After honored,\n# exponential backoff with full jitter, a fresh timeout budget per attempt), and\n# the reverse on_response onion.\nfrom __future__ import annotations\n\nimport asyncio\nimport random\nimport time\nimport uuid\nfrom typing import Any, Dict, List, Optional\n\nimport httpx\n\nfrom ._errors import ApiTimeoutError\n\n_IDEMPOTENT_METHODS = {"GET", "HEAD", "PUT", "DELETE", "OPTIONS"}\n_TRANSIENT_STATUS = {408, 429, 500, 502, 503, 504}\n\n\ndef _default_retry_on(method: str, headers: Dict[str, str], response: Optional[httpx.Response]) -> bool:\n safe = method.upper() in _IDEMPOTENT_METHODS or "Idempotency-Key" in headers\n if not safe:\n return False\n return response is None or response.status_code in _TRANSIENT_STATUS\n\n\ndef _retry_delay(retry: Dict[str, Any], attempt: int, retry_after: Optional[str]) -> float:\n if retry_after:\n try:\n return float(retry_after)\n except ValueError:\n pass # HTTP-date form: fall through to backoff\n base = float(retry.get("retry_delay", 1.0))\n raw = base if retry.get("retry_strategy") == "fixed" else base * (2 ** (attempt - 1))\n return random.uniform(0, raw) if retry.get("jitter", True) is not False else raw\n\n\ndef send(\n client: httpx.Client,\n config: Dict[str, Any],\n op: Dict[str, Any],\n url: str,\n *,\n method: str,\n headers: Optional[Dict[str, str]] = None,\n params: Optional[Dict[str, Any]] = None,\n json_body: Any = None,\n content: Any = None,\n data: Any = None,\n files: Any = None,\n timeout: Optional[float] = None,\n idempotency_key: Any = None,\n retry: Optional[Dict[str, Any]] = None,\n) -> httpx.Response:\n merged_retry: Dict[str, Any] = {**(config.get("retry") or {}), **(retry or {})}\n effective_timeout = timeout if timeout is not None else config.get("timeout")\n merged_headers: Dict[str, str] = {**(config.get("headers") or {}), **(headers or {})}\n\n # One stable key per LOGICAL call — set before the retry loop so every\n # attempt re-sends the same key; a caller-provided header always wins.\n key = idempotency_key if idempotency_key is not None else config.get("idempotency_key")\n if (\n key not in (None, False)\n and method.upper() in ("POST", "PATCH")\n and "Idempotency-Key" not in merged_headers\n ):\n merged_headers["Idempotency-Key"] = (\n key if isinstance(key, str) else key() if callable(key) else str(uuid.uuid4())\n )\n\n context = {\n "url": url,\n "method": method.upper(),\n "headers": merged_headers,\n "body": json_body,\n "operation": op,\n }\n middleware: List[Any] = config.get("middleware") or []\n for mw in middleware:\n on_request = getattr(mw, "on_request", None) or (mw.get("on_request") if isinstance(mw, dict) else None)\n if on_request:\n on_request(context)\n\n max_attempts = 1 + int(merged_retry.get("retries", 0))\n retry_on = merged_retry.get("retry_on") or (\n lambda ctx: _default_retry_on(context["method"], context["headers"], ctx.get("response"))\n )\n\n attempt = 0\n while True:\n attempt += 1\n try:\n response = client.request(\n context["method"],\n context["url"],\n headers=context["headers"],\n params=params,\n json=context["body"] if content is None and files is None and data is None else None,\n content=content,\n data=data,\n files=files,\n timeout=effective_timeout if effective_timeout is not None else httpx.USE_CLIENT_DEFAULT,\n )\n except httpx.TimeoutException:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n time.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise ApiTimeoutError(op.get("id", "?"), float(effective_timeout or 0), attempt) from None\n except httpx.TransportError:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n time.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise\n\n for mw in reversed(middleware):\n on_response = getattr(mw, "on_response", None) or (mw.get("on_response") if isinstance(mw, dict) else None)\n if on_response:\n replaced = on_response(response, context)\n if replaced is not None:\n response = replaced\n\n if (\n not response.is_success\n and attempt < max_attempts\n and retry_on({"attempt": attempt, "response": response})\n ):\n time.sleep(_retry_delay(merged_retry, attempt, response.headers.get("retry-after")))\n continue\n return response\n\n\nasync def send_async(\n client: httpx.AsyncClient,\n config: Dict[str, Any],\n op: Dict[str, Any],\n url: str,\n *,\n method: str,\n headers: Optional[Dict[str, str]] = None,\n params: Optional[Dict[str, Any]] = None,\n json_body: Any = None,\n content: Any = None,\n data: Any = None,\n files: Any = None,\n timeout: Optional[float] = None,\n idempotency_key: Any = None,\n retry: Optional[Dict[str, Any]] = None,\n) -> httpx.Response:\n """The async mirror of send() — same retry/timeout/idempotency semantics."""\n merged_retry: Dict[str, Any] = {**(config.get("retry") or {}), **(retry or {})}\n effective_timeout = timeout if timeout is not None else config.get("timeout")\n merged_headers: Dict[str, str] = {**(config.get("headers") or {}), **(headers or {})}\n key = idempotency_key if idempotency_key is not None else config.get("idempotency_key")\n if (\n key not in (None, False)\n and method.upper() in ("POST", "PATCH")\n and "Idempotency-Key" not in merged_headers\n ):\n merged_headers["Idempotency-Key"] = (\n key if isinstance(key, str) else key() if callable(key) else str(uuid.uuid4())\n )\n context = {\n "url": url,\n "method": method.upper(),\n "headers": merged_headers,\n "body": json_body,\n "operation": op,\n }\n middleware: List[Any] = config.get("middleware") or []\n for mw in middleware:\n on_request = getattr(mw, "on_request", None) or (mw.get("on_request") if isinstance(mw, dict) else None)\n if on_request:\n on_request(context)\n max_attempts = 1 + int(merged_retry.get("retries", 0))\n retry_on = merged_retry.get("retry_on") or (\n lambda ctx: _default_retry_on(context["method"], context["headers"], ctx.get("response"))\n )\n attempt = 0\n while True:\n attempt += 1\n try:\n response = await client.request(\n context["method"],\n context["url"],\n headers=context["headers"],\n params=params,\n json=context["body"] if content is None and files is None and data is None else None,\n content=content,\n data=data,\n files=files,\n timeout=effective_timeout if effective_timeout is not None else httpx.USE_CLIENT_DEFAULT,\n )\n except httpx.TimeoutException:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n await asyncio.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise ApiTimeoutError(op.get("id", "?"), float(effective_timeout or 0), attempt) from None\n except httpx.TransportError:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n await asyncio.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise\n for mw in reversed(middleware):\n on_response = getattr(mw, "on_response", None) or (mw.get("on_response") if isinstance(mw, dict) else None)\n if on_response:\n replaced = on_response(response, context)\n if replaced is not None:\n response = replaced\n if (\n not response.is_success\n and attempt < max_attempts\n and retry_on({"attempt": attempt, "response": response})\n ):\n await asyncio.sleep(_retry_delay(merged_retry, attempt, response.headers.get("retry-after")))\n continue\n return response\n', '_paginate.py': diff --git a/packages/client-generator/src/generators/__tests__/go.test.ts b/packages/client-generator/src/generators/__tests__/go.test.ts index ccb5e24257..f47bfb5e44 100644 --- a/packages/client-generator/src/generators/__tests__/go.test.ts +++ b/packages/client-generator/src/generators/__tests__/go.test.ts @@ -169,7 +169,19 @@ describe('renderGoModels', () => { const CAFE: ApiModel = { title: 'Cafe', version: '1.0.0', - serverUrl: 'https://api.cafe.example', + serverUrl: 'https://api.cafe.example/organizations/unknown', + servers: [ + { + url: 'https://api.cafe.example/organizations/{organizationId}', + description: 'Live server', + variables: [{ name: 'organizationId', default: 'unknown' }], + }, + { + url: 'https://api-sandbox.cafe.example/organizations/{organizationId}', + description: 'Sandbox server', + variables: [{ name: 'organizationId', default: 'unknown' }], + }, + ], services: [ { name: 'Orders', @@ -370,4 +382,14 @@ describe('goGenerator parity features', () => { expect(out).toContain('contentType, reader, err := toMultipart(body)'); expectGoCompiles(out); }); + + it('emits one URL function per declared server with variables as parameters', () => { + const out = generateGo(); + expect(out).toContain('func LiveServerURL(organizationId string) string {'); + expect(out).toContain('func SandboxServerURL(organizationId string) string {'); + expect(out).toContain('return "https://api.cafe.example/organizations/" + organizationId'); + // Go has no default arguments; the spec default lives in the doc comment. + expect(out).toContain('organizationId default: "unknown"'); + expectGoCompiles(out); + }); }); diff --git a/packages/client-generator/src/generators/__tests__/python.test.ts b/packages/client-generator/src/generators/__tests__/python.test.ts index 25e54c7b36..575ba09fca 100644 --- a/packages/client-generator/src/generators/__tests__/python.test.ts +++ b/packages/client-generator/src/generators/__tests__/python.test.ts @@ -7,6 +7,7 @@ import type { ApiModel, SchemaModel } from '../../intermediate-representation/mo import { pythonGenerator, renderPythonModels } from '../python/index.js'; const hasPython = spawnSync('python3', ['--version']).status === 0; +const hasHttpx = hasPython && spawnSync('python3', ['-c', 'import httpx']).status === 0; /** Assert the rendered source is valid Python (skipped when python3 is absent). */ function expectCompiles(source: string): void { @@ -172,7 +173,19 @@ describe('renderPythonModels', () => { const CAFE: ApiModel = { title: 'Cafe', version: '1.0.0', - serverUrl: 'https://api.cafe.example', + serverUrl: 'https://api.cafe.example/organizations/unknown', + servers: [ + { + url: 'https://api.cafe.example/organizations/{organizationId}', + description: 'Live server', + variables: [{ name: 'organizationId', default: 'unknown' }], + }, + { + url: 'https://api-sandbox.cafe.example/organizations/{organizationId}', + description: 'Sandbox server', + variables: [{ name: 'organizationId', default: 'unknown' }], + }, + ], services: [ { name: 'Orders', @@ -387,4 +400,76 @@ describe('pythonGenerator parity features', () => { expect(out).toContain('data=form_data, files=form_files'); expectCompiles(out); }); + + it('emits a Servers class with keyword arguments defaulting to the spec defaults', () => { + const out = generate(); + expect(out).toContain('class Servers:'); + expect(out).toContain('def live_server(organization_id: str = "unknown") -> str:'); + expect(out).toContain('def sandbox_server(organization_id: str = "unknown") -> str:'); + expect(out).toContain('return "https://api.cafe.example/organizations/" + organization_id'); + expectCompiles(out); + }); + + it('decodes discriminated unions through the DISCRIMINATORS registry', () => { + const files = pythonGenerator({ + model: { + title: 'Pets', + version: '1.0.0', + serverUrl: 'https://pets.example', + services: [], + schemas: [ + { name: 'Cat', schema: { kind: 'object', properties: [] } }, + { + name: 'Dog', + schema: { + kind: 'object', + properties: [{ name: 'barks', schema: { kind: 'scalar', scalar: 'boolean' } }], + }, + }, + { + name: 'Pet', + schema: { + kind: 'union', + members: [ + { kind: 'ref', name: 'Cat' }, + { kind: 'ref', name: 'Dog' }, + ], + discriminator: { + propertyName: 'petType', + mapping: [ + { value: 'cat', schemaName: 'Cat' }, + { value: 'dog', schemaName: 'Dog' }, + ], + }, + }, + }, + ], + securitySchemes: [], + } as unknown as ApiModel, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: {}, + }); + const out = files[0].content; + expect(out).toContain('DISCRIMINATORS[Pet] = ("petType", {"cat": Cat, "dog": Dog})'); + // Behavioral: first-member-wins would hydrate {"petType": "dog"} as Cat (empty + // dataclasses accept anything); the registry must dispatch it to Dog. + if (!hasHttpx) return; + const dir = mkdtempSync(join(tmpdir(), 'py-dispatch-')); + try { + writeFileSync(join(dir, 'client.py'), out); + const run = spawnSync( + 'python3', + [ + '-c', + 'import client; print(type(client.decode(client.Pet, {"petType": "dog"})).__name__)', + ], + { cwd: dir, encoding: 'utf-8' } + ); + expect(run.status, run.stderr).toBe(0); + expect(run.stdout.trim()).toBe('Dog'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); diff --git a/packages/client-generator/src/generators/go/AGENTS.md b/packages/client-generator/src/generators/go/AGENTS.md index a1278fafee..bfab85d6a7 100644 --- a/packages/client-generator/src/generators/go/AGENTS.md +++ b/packages/client-generator/src/generators/go/AGENTS.md @@ -23,6 +23,11 @@ runtime. Go ≥ 1.21, standard library only — zero dependencies. dispatcher; **allOf** is flattened. - **Errors:** `(T, error)` returns ARE the error mode — `errorMode` does not change the output. Non-2xx → `*APIError`; timeouts → `*TimeoutError`. +- **Servers:** when the description declares servers, one `URL(...)` function per + server is emitted (named from the server description); server VARIABLES become string + parameters (Go has no defaults — the doc comment states the spec default), so templated + base URLs need no manual string building. The client's baked default stays `servers[0]` + with variable defaults substituted. - **Parity surface:** auth, retries with `Retry-After` + jittered backoff, per-attempt `context.WithTimeout`, idempotency keys, middleware, pagination (`Pages`/`Items` as `func(yield func(T, error) bool)` — `range`-over-func needs Go ≥ 1.23; 1.21 calls diff --git a/packages/client-generator/src/generators/go/index.ts b/packages/client-generator/src/generators/go/index.ts index efa6b57c58..cca159c7e5 100644 --- a/packages/client-generator/src/generators/go/index.ts +++ b/packages/client-generator/src/generators/go/index.ts @@ -25,6 +25,7 @@ import type { OperationModel, PropertyModel, SchemaModel, + ServerModel, } from '../../intermediate-representation/model.js'; import type { CodeSample, Generator, SampleContext } from '../types.js'; @@ -660,6 +661,62 @@ function writeGoPaginationWrappers( writer.blank(); } +/** The server URL as a Go expression: literals concatenated with declared-variable params. */ +function serverUrlExpression(server: ServerModel): string { + const declared = new Set(server.variables.map((variable) => variable.name)); + const parts: string[] = []; + let literal = ''; + let rest = server.url; + const template = /\{([^{}]+)\}/; + for (let match = template.exec(rest); match !== null; match = template.exec(rest)) { + literal += rest.slice(0, match.index); + if (declared.has(match[1])) { + if (literal !== '') parts.push(JSON.stringify(literal)); + literal = ''; + parts.push(identifierFor(match[1], { style: 'camel', reserved: GO })); + } else { + // An undeclared variable has nothing to substitute; keep its placeholder visible. + literal += match[0]; + } + rest = rest.slice(match.index + match[0].length); + } + literal += rest; + if (literal !== '' || parts.length === 0) parts.push(JSON.stringify(literal)); + return parts.join(' + '); +} + +/** One `URL` function per declared server; server variables become parameters. */ +function writeGoServers(writer: Printer, model: ApiModel): void { + const servers = model.servers ?? []; + if (servers.length === 0) return; + const usedNames = new Set(); + servers.forEach((server, index) => { + let name = `${exported(server.description ?? `server${index + 1}`)}URL`; + if (usedNames.has(name)) name = `${name}${index + 1}`; + usedNames.add(name); + const params = server.variables.map( + (variable) => `${identifierFor(variable.name, { style: 'camel', reserved: GO })} string` + ); + const defaults = server.variables + .map( + (variable) => + `${identifierFor(variable.name, { style: 'camel', reserved: GO })} default: ${JSON.stringify(variable.default)}` + ) + .join(', '); + writer.line( + `// ${name} returns the ${JSON.stringify(server.description ?? server.url)} base URL${defaults === '' ? '.' : ` (${defaults}).`}` + ); + writer.block( + `func ${name}(${params.join(', ')}) string {`, + () => { + writer.line(`return ${serverUrlExpression(server)}`); + }, + '}' + ); + writer.blank(); + }); +} + /** The whole generated file: models + embedded runtime + operations table + Client. */ export const goGenerator: Generator = ({ model, outputPath, emit }) => { const writer = new Printer('\t'); @@ -705,6 +762,7 @@ export const goGenerator: Generator = ({ model, outputPath, emit }) => { writer.line(stripHeader(renderGoModels(model))); writer.blank(); + writeGoServers(writer, model); writer.line('// ─── Embedded runtime (@redocly/client-generator go runtime) ───'); writer.line(stripHeader(GO_RUNTIME_SOURCE)); writer.blank(); diff --git a/packages/client-generator/src/generators/python/AGENTS.md b/packages/client-generator/src/generators/python/AGENTS.md index 3630e3d9f3..936bb13376 100644 --- a/packages/client-generator/src/generators/python/AGENTS.md +++ b/packages/client-generator/src/generators/python/AGENTS.md @@ -19,13 +19,18 @@ One self-contained `.py`: typed dataclass models, a sync `Client` and an a - **Naming:** fields/methods snake*case via `identifierFor(..., RESERVED_WORDS.python)`; reserved words get a trailing underscore (`class*`); `+1`/`-1`become`plus_1`/`minus_1`. - **Enums** are `class X(str, Enum)` with SCREAMING members; **unions** are `Union[...]` - aliases, decoded by trying each member in order (the first that hydrates wins — see - `_decode.py`); a discriminator, when present, is emitted as a table COMMENT on the - alias, not as runtime dispatch. (Discriminator-driven dispatch is a known improvement - candidate: update this paragraph first, then `_decode.py`.) **allOf** is flattened via - `flattenAllOf`. + aliases. A DISCRIMINATED union registers its dispatch table in the runtime's + `DISCRIMINATORS` registry (`DISCRIMINATORS[Pet] = ("petType", {"cat": Cat, ...})`), + and `decode()` routes through it — `isinstance` narrowing works on decoded members. + Undiscriminated unions decode by trying each member in order (the first that + hydrates wins — see `_decode.py`). **allOf** is flattened via `flattenAllOf`. - **Errors:** `errorMode` maps to raising `ApiError` (default) or returning a `Result` dataclass — the only generator with both modes outside TypeScript. +- **Servers:** when the description declares servers, a `Servers` class is emitted with + one static method per server; server VARIABLES become keyword arguments defaulting to + the spec's defaults (`Servers.production(organization_id="org_x")`), so templated base + URLs need no manual string building. The client's baked default stays `servers[0]` + with variable defaults substituted. - **Parity surface:** auth (bearer/basic/apiKey), retries with `Retry-After` + jittered backoff, timeouts, idempotency keys, middleware, pagination (`_pages()` / `_items()` + `aiter` mirrors), SSE (`iter_sse`/`aiter_sse`), multipart. diff --git a/packages/client-generator/src/generators/python/index.ts b/packages/client-generator/src/generators/python/index.ts index 216cc6bd9d..905de64a05 100644 --- a/packages/client-generator/src/generators/python/index.ts +++ b/packages/client-generator/src/generators/python/index.ts @@ -22,6 +22,7 @@ import type { OperationModel, PropertyModel, SchemaModel, + ServerModel, } from '../../intermediate-representation/model.js'; import type { CodeSample, Generator, SampleContext } from '../types.js'; @@ -175,6 +176,77 @@ export function renderPythonModels(model: ApiModel): string { return writer.toString(); } +/** The server URL as a Python expression: literals concatenated with declared-variable args. */ +function serverUrlExpression(server: ServerModel): string { + const declared = new Set(server.variables.map((variable) => variable.name)); + const parts: string[] = []; + let literal = ''; + let rest = server.url; + const template = /\{([^{}]+)\}/; + for (let match = template.exec(rest); match !== null; match = template.exec(rest)) { + literal += rest.slice(0, match.index); + if (declared.has(match[1])) { + if (literal !== '') parts.push(JSON.stringify(literal)); + literal = ''; + parts.push(fieldName(match[1]).python); + } else { + // An undeclared variable has nothing to substitute; keep its placeholder visible. + literal += match[0]; + } + rest = rest.slice(match.index + match[0].length); + } + literal += rest; + if (literal !== '' || parts.length === 0) parts.push(JSON.stringify(literal)); + return parts.join(' + '); +} + +/** One static method per declared server; server variables become keyword arguments. */ +function writePythonServers(writer: Printer, model: ApiModel): void { + const servers = model.servers ?? []; + if (servers.length === 0) return; + const usedNames = new Set(); + writer.block('class Servers:', () => { + writer.line( + '"""The declared servers; variables default to the values from the description."""' + ); + writer.blank(); + servers.forEach((server, index) => { + let name = identifierFor(server.description ?? `server${index + 1}`, { + style: 'snake', + reserved: PY, + }); + if (usedNames.has(name)) name = `${name}_${index + 1}`; + usedNames.add(name); + const params = server.variables.map( + (variable) => + `${fieldName(variable.name).python}: str = ${JSON.stringify(variable.default)}` + ); + if (index > 0) writer.blank(); + writer.line('@staticmethod'); + writer.block(`def ${name}(${params.join(', ')}) -> str:`, () => { + writer.line(`return ${serverUrlExpression(server)}`); + }); + }); + }); + writer.blank(); +} + +/** `DISCRIMINATORS[Pet] = ("petType", {"cat": Cat, ...})` registration lines. */ +function discriminatorRegistrations(model: ApiModel): string[] { + const lines: string[] = []; + for (const { name, schema } of model.schemas) { + const cases = discriminatorCases(schema, model); + if (cases === undefined) continue; + const mapping = cases.cases + .map((entry) => `${JSON.stringify(entry.value)}: ${className(entry.schemaName)}`) + .join(', '); + lines.push( + `DISCRIMINATORS[${className(name)}] = (${JSON.stringify(cases.property)}, {${mapping}})` + ); + } + return lines; +} + /** The operation's primary JSON success schema, or undefined for void/no-body ops. */ function successSchema(op: OperationModel): SchemaModel | undefined { return op.successResponses.find((r) => r.contentType.toLowerCase().includes('json'))?.schema; @@ -531,6 +603,7 @@ export const pythonGenerator: Generator = ({ model, outputPath, emit }) => { writer.line(renderPythonModels(model).trimEnd()); writer.blank(); writer.blank(); + writePythonServers(writer, 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 @@ -546,6 +619,12 @@ export const pythonGenerator: Generator = ({ model, outputPath, emit }) => { writer.blank(); } writer.blank(); + const registrations = discriminatorRegistrations(model); + if (registrations.length > 0) { + writer.line('# Discriminated unions dispatch by their property inside decode().'); + for (const registration of registrations) writer.line(registration); + writer.blank(); + } writer.block('def _safe_json(response: httpx.Response) -> Any:', () => { writer.block('try:', () => { writer.line('return response.json()'); From 4772a5d03c8a2b88251576aa420b34c452f5537c Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 4 Aug 2026 15:15:54 +0300 Subject: [PATCH 070/211] chore: remove redundant comments from cli build script and generate-client --- packages/cli/scripts/build.mjs | 2 -- packages/cli/src/commands/generate-client.ts | 7 ------- 2 files changed, 9 deletions(-) diff --git a/packages/cli/scripts/build.mjs b/packages/cli/scripts/build.mjs index 1ecf3a2e31..5363bbc3ec 100644 --- a/packages/cli/scripts/build.mjs +++ b/packages/cli/scripts/build.mjs @@ -93,8 +93,6 @@ writeFileSync( `Third-party software bundled in @redocly/cli\n\n${sections.join('\n\n')}\n` ); -// Ship the eject assets (generator files + the authoring AGENTS.md) inside lib/, so the -// bundled CLI finds them relative to its own module in both the repo and the published package. cpSync( path.join(packageDir, '..', 'client-generator', 'eject-assets'), path.join(packageDir, 'lib', 'eject-assets'), diff --git a/packages/cli/src/commands/generate-client.ts b/packages/cli/src/commands/generate-client.ts index 0322f9fd5d..eb4a7f8175 100644 --- a/packages/cli/src/commands/generate-client.ts +++ b/packages/cli/src/commands/generate-client.ts @@ -52,8 +52,6 @@ function fileNameFor(name: string): string { return `${name.replace(/[\\/]/g, '_')}.client.ts`; } -// Accepts an absolute http(s) URL or a root-relative path; rejects bare hostnames, -// protocol-relative `//host`, and non-http(s) schemes. function isValidServerUrl(value: string): boolean { if (value.startsWith('//')) return false; if (value.startsWith('/')) return true; @@ -84,9 +82,6 @@ export async function handleGenerateClient({ dateType: argv['date-type'], mockData: argv['mock-data'], mockSeed: argv['mock-seed'], - // Like `setup` below: flag paths resolve against the cwd, while config-file entries - // resolve against the config dir (in `resolveGenerators`). Package specifiers and - // built-in names pass through. generators: argv.generator?.map((specifier) => specifier.startsWith('.') ? resolvePath(specifier) : specifier ), @@ -121,8 +116,6 @@ export async function handleGenerateClient({ for (const { path, alias } of entrypoints) { const name = alias ?? basename(path, extname(path)); - // `forAlias` layers the api's entry over the root config, so `client` is the - // per-api block when the api declares one and the top-level block otherwise. const aliasConfig = config.forAlias(alias); const { client, clientOutput } = aliasConfig.resolvedConfig; const clientBlock = resolveSetup( From 33e078fa9f2dfde7d53b060f9e1f3884d55aab28 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 4 Aug 2026 15:26:20 +0300 Subject: [PATCH 071/211] refactor: rename Printer instances from writer to printer --- .../src/authoring/__tests__/printer.test.ts | 32 +- .../src/generators/go/index.ts | 334 +++++++++--------- .../src/generators/php/index.ts | 282 +++++++-------- .../src/generators/python/index.ts | 286 +++++++-------- 4 files changed, 468 insertions(+), 466 deletions(-) diff --git a/packages/client-generator/src/authoring/__tests__/printer.test.ts b/packages/client-generator/src/authoring/__tests__/printer.test.ts index 88ff9b04e3..07e462cc16 100644 --- a/packages/client-generator/src/authoring/__tests__/printer.test.ts +++ b/packages/client-generator/src/authoring/__tests__/printer.test.ts @@ -2,35 +2,35 @@ import { Printer } from '../printer.js'; describe('Printer', () => { it('builds indented blocks in any language without manual whitespace bookkeeping', () => { - const writer = new Printer(); - writer.line('class Pet:').indent(() => { - writer.line('def __init__(self):').indent(() => { - writer.line('self.name = name'); + const printer = new Printer(); + printer.line('class Pet:').indent(() => { + printer.line('def __init__(self):').indent(() => { + printer.line('self.name = name'); }); }); - expect(writer.toString()).toBe('class Pet:\n def __init__(self):\n self.name = name\n'); + expect(printer.toString()).toBe('class Pet:\n def __init__(self):\n self.name = name\n'); }); it('block() without a close suits dedent-terminated languages (Python)', () => { - const writer = new Printer(' '); - writer.block('class Pet:', () => { - writer.line('name: str'); + const printer = new Printer(' '); + printer.block('class Pet:', () => { + printer.line('name: str'); }); - writer.line('PETS = []'); - expect(writer.toString()).toBe('class Pet:\n name: str\nPETS = []\n'); + printer.line('PETS = []'); + expect(printer.toString()).toBe('class Pet:\n name: str\nPETS = []\n'); }); it('block() wraps open/body/close; blank() emits an empty line without indentation', () => { - const writer = new Printer(' '); - writer.block( + const printer = new Printer(' '); + printer.block( 'func main() {', () => { - writer.line('run()'); - writer.blank(); - writer.line('done()'); + printer.line('run()'); + printer.blank(); + printer.line('done()'); }, '}' ); - expect(writer.toString()).toBe('func main() {\n run()\n\n done()\n}\n'); + expect(printer.toString()).toBe('func main() {\n run()\n\n done()\n}\n'); }); }); diff --git a/packages/client-generator/src/generators/go/index.ts b/packages/client-generator/src/generators/go/index.ts index cca159c7e5..21d94a9695 100644 --- a/packages/client-generator/src/generators/go/index.ts +++ b/packages/client-generator/src/generators/go/index.ts @@ -80,21 +80,21 @@ export function goType(schema: SchemaModel): string { } } -function writeDocComment(writer: Printer, name: string, description?: string): void { +function writeDocComment(printer: Printer, name: string, description?: string): void { const lines = docText(description); if (lines.length === 0) return; - writer.line(`// ${name} — ${lines[0]}`); - for (const line of lines.slice(1)) writer.line(`// ${line}`); + printer.line(`// ${name} — ${lines[0]}`); + for (const line of lines.slice(1)) printer.line(`// ${line}`); } function writeStruct( - writer: Printer, + printer: Printer, name: string, properties: PropertyModel[], description?: string ): void { - writeDocComment(writer, exported(name), description); - writer.block( + writeDocComment(printer, exported(name), description); + printer.block( `type ${exported(name)} struct {`, () => { for (const property of properties) { @@ -112,51 +112,51 @@ function writeStruct( } tag = `\`json:"${property.name},omitempty"\``; } - writer.line(`${field} ${fieldType} ${tag}`); + printer.line(`${field} ${fieldType} ${tag}`); } }, '}' ); - writer.blank(); + printer.blank(); } /** Render every named schema: typed-const enums, structs (allOf flattened), union dispatchers. */ export function renderGoModels(model: ApiModel): string { - const writer = new Printer('\t'); - writer.line('package client'); - writer.blank(); + const printer = new Printer('\t'); + printer.line('package client'); + printer.blank(); const needsJSON = model.schemas.some( ({ schema }) => discriminatorCases(schema, model) !== undefined ); if (needsJSON) { - writer.line('import "encoding/json"'); - writer.blank(); + printer.line('import "encoding/json"'); + printer.blank(); } for (const { name, schema } of model.schemas) { const asEnum = enumValues(schema); if (asEnum !== undefined) { const base = asEnum.scalar === 'string' ? 'string' : 'int64'; - writeDocComment(writer, exported(name), schema.description); - writer.line(`type ${exported(name)} ${base}`); - writer.blank(); - writer.block( + writeDocComment(printer, exported(name), schema.description); + printer.line(`type ${exported(name)} ${base}`); + printer.blank(); + printer.block( 'const (', () => { asEnum.values.forEach((value) => { const member = exported(name) + casing.pascal(String(value)); - writer.line(`${member} ${exported(name)} = ${JSON.stringify(value)}`); + printer.line(`${member} ${exported(name)} = ${JSON.stringify(value)}`); }); }, ')' ); - writer.blank(); + printer.blank(); continue; } if (schema.kind === 'object' || schema.kind === 'intersection') { const flat = flattenAllOf(schema, model); if (flat !== undefined) { - writeStruct(writer, name, flat.properties, flat.description ?? schema.description); + writeStruct(printer, name, flat.properties, flat.description ?? schema.description); continue; } } @@ -166,57 +166,57 @@ export function renderGoModels(model: ApiModel): string { const table = cases.cases .map((entry) => `${entry.value} -> ${exported(entry.schemaName)}`) .join(', '); - writer.line(`// ${typeName} is a discriminated union ("${cases.property}"): ${table}.`); - writer.line(`type ${typeName} = any`); - writer.blank(); - writer.line( + 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}".` ); - writer.block( + printer.block( `func Unmarshal${typeName}(data []byte) (${typeName}, error) {`, () => { - writer.block( + printer.block( 'var probe struct {', () => { - writer.line(`Discriminant string \`json:"${cases.property}"\``); + printer.line(`Discriminant string \`json:"${cases.property}"\``); }, '}' ); - writer.block( + printer.block( 'if err := json.Unmarshal(data, &probe); err != nil {', () => { - writer.line('return nil, err'); + printer.line('return nil, err'); }, '}' ); - writer.block( + printer.block( 'switch probe.Discriminant {', () => { for (const entry of cases.cases) { - writer.block(`case ${JSON.stringify(entry.value)}:`, () => { - writer.line(`var value ${exported(entry.schemaName)}`); - writer.line('err := json.Unmarshal(data, &value)'); - writer.line('return value, err'); + printer.block(`case ${JSON.stringify(entry.value)}:`, () => { + printer.line(`var value ${exported(entry.schemaName)}`); + printer.line('err := json.Unmarshal(data, &value)'); + printer.line('return value, err'); }); } }, '}' ); - writer.line('var fallback any'); - writer.line('err := json.Unmarshal(data, &fallback)'); - writer.line('return fallback, err'); + printer.line('var fallback any'); + printer.line('err := json.Unmarshal(data, &fallback)'); + printer.line('return fallback, err'); }, '}' ); - writer.blank(); + printer.blank(); continue; } // Everything else (plain unions, scalar aliases, records) becomes a type alias. - writeDocComment(writer, exported(name), schema.description); - writer.line(`type ${exported(name)} = ${goType(schema)}`); - writer.blank(); + writeDocComment(printer, exported(name), schema.description); + printer.line(`type ${exported(name)} = ${goType(schema)}`); + printer.blank(); } - return writer.toString(); + return printer.toString(); } /** The operation's primary JSON success schema, or undefined for void/no-body ops. */ @@ -326,7 +326,7 @@ function goPaginationLiteral(rule: NeutralPaginationRule): string { return `&PaginationSpec{${fields.join(', ')}}`; } -function writeGoMethod(writer: Printer, op: OperationModel, ident: string): void { +function writeGoMethod(printer: Printer, op: OperationModel, ident: string): void { const pathArgs = op.pathParams.map((param) => ({ param, go: identifierFor(param.name, { style: 'camel', reserved: GO }), @@ -350,23 +350,23 @@ function writeGoMethod(writer: Printer, op: OperationModel, ident: string): void : `(${returnType}, error)`; const fail = (errExpr: string) => returnType === undefined ? `return ${errExpr}` : `return out, ${errExpr}`; - writeDocComment(writer, ident, op.summary); - writer.block( + writeDocComment(printer, ident, op.summary); + printer.block( `func (c *Client) ${ident}(${args.join(', ')}) ${returns} {`, () => { - if (sse === undefined && returnType !== undefined) writer.line(`var out ${returnType}`); - writer.line(`op := operations[${JSON.stringify(op.specName ?? op.name)}]`); - writer.line('authHeaders, query := resolveAuth(op.Security, c.config.Auth)'); + if (sse === undefined && returnType !== undefined) printer.line(`var out ${returnType}`); + printer.line(`op := operations[${JSON.stringify(op.specName ?? op.name)}]`); + printer.line('authHeaders, query := resolveAuth(op.Security, c.config.Auth)'); if (hasParams) { - writer.block( + printer.block( 'if params != nil {', () => { for (const param of op.queryParams) { const field = exported(param.name); - writer.block( + printer.block( `if params.${field} != nil {`, () => { - writer.line( + printer.line( `query.Set(${JSON.stringify(param.name)}, ${goQueryFormat(`*params.${field}`, goType(param.schema))})` ); }, @@ -380,35 +380,35 @@ function writeGoMethod(writer: Printer, op: OperationModel, ident: string): void const pathDict = pathArgs .map(({ param, go, type }) => `${JSON.stringify(param.name)}: ${goQueryFormat(go, type)}`) .join(', '); - writer.line( + printer.line( `requestURL := buildURL(c.config.ServerURL, op.Path, map[string]string{${pathDict}})` ); if (sse !== undefined) { - writer.block( + printer.block( 'open := func(extraHeaders map[string]string) (*http.Response, error) {', () => { - writer.line('merged := map[string]string{}'); - writer.block( + printer.line('merged := map[string]string{}'); + printer.block( 'for key, value := range authHeaders {', () => { - writer.line('merged[key] = value'); + printer.line('merged[key] = value'); }, '}' ); - writer.block( + printer.block( 'for key, value := range extraHeaders {', () => { - writer.line('merged[key] = value'); + printer.line('merged[key] = value'); }, '}' ); - writer.line( + printer.line( 'return send(ctx, &c.config, requestSpec{OperationID: op.ID, Method: op.Method, URL: requestURL, Headers: merged, Query: query})' ); }, '}' ); - writer.line( + printer.line( `return iterSSE(open, ${sse.schema !== undefined && sse.schema.kind !== 'unknown'})` ); return; @@ -421,64 +421,64 @@ function writeGoMethod(writer: Printer, op: OperationModel, ident: string): void 'Query: query', ]; if (op.requestBody && isMultipart(op)) { - writer.line('contentType, reader, err := toMultipart(body)'); - writer.block( + printer.line('contentType, reader, err := toMultipart(body)'); + printer.block( 'if err != nil {', () => { - writer.line(fail('err')); + printer.line(fail('err')); }, '}' ); specFields.push('Body: reader'); specFields.push('ContentType: contentType'); } else if (op.requestBody) { - writer.line('payload, err := json.Marshal(body)'); - writer.block( + printer.line('payload, err := json.Marshal(body)'); + printer.block( 'if err != nil {', () => { - writer.line(fail('err')); + printer.line(fail('err')); }, '}' ); specFields.push('Body: bytes.NewReader(payload)'); specFields.push(`ContentType: ${JSON.stringify(op.requestBody.contentType)}`); } - writer.line(`resp, err := send(ctx, &c.config, requestSpec{${specFields.join(', ')}})`); - writer.block( + printer.line(`resp, err := send(ctx, &c.config, requestSpec{${specFields.join(', ')}})`); + printer.block( 'if err != nil {', () => { - writer.line(fail('err')); + printer.line(fail('err')); }, '}' ); - writer.block( + printer.block( 'if resp.StatusCode >= 400 {', () => { - writer.line(fail('apiErrorFrom(resp, requestURL)')); + printer.line(fail('apiErrorFrom(resp, requestURL)')); }, '}' ); if (returnType === undefined) { - writer.line('return decodeJSON(resp, nil)'); + printer.line('return decodeJSON(resp, nil)'); } else { - writer.block( + printer.block( 'if err := decodeJSON(resp, &out); err != nil {', () => { - writer.line('return out, err'); + printer.line('return out, err'); }, '}' ); - writer.line('return out, nil'); + printer.line('return out, nil'); } }, '}' ); - writer.blank(); + printer.blank(); } /** `Pages` / `Items` iterators over the runtime's `iterPages`, hydrated via `reencode`. */ function writeGoPaginationWrappers( - writer: Printer, + printer: Printer, op: OperationModel, ident: string, pageType: string, @@ -497,18 +497,18 @@ function writeGoPaginationWrappers( ].join(', '); const writeCallClosure = () => { - writer.line(`op := operations[${JSON.stringify(op.specName ?? op.name)}]`); - writer.line('base := url.Values{}'); + printer.line(`op := operations[${JSON.stringify(op.specName ?? op.name)}]`); + printer.line('base := url.Values{}'); if (hasParams) { - writer.block( + printer.block( 'if params != nil {', () => { for (const param of op.queryParams) { const field = exported(param.name); - writer.block( + printer.block( `if params.${field} != nil {`, () => { - writer.line( + printer.line( `base.Set(${JSON.stringify(param.name)}, ${goQueryFormat(`*params.${field}`, goType(param.schema))})` ); }, @@ -519,17 +519,17 @@ function writeGoPaginationWrappers( '}' ); } - writer.block( + printer.block( 'call := func(pageParams url.Values) (any, *http.Response, error) {', () => { - writer.line('authHeaders, query := resolveAuth(op.Security, c.config.Auth)'); - writer.block( + printer.line('authHeaders, query := resolveAuth(op.Security, c.config.Auth)'); + printer.block( 'for key, values := range pageParams {', () => { - writer.block( + printer.block( 'for _, value := range values {', () => { - writer.line('query.Set(key, value)'); + printer.line('query.Set(key, value)'); }, '}' ); @@ -539,63 +539,63 @@ function writeGoPaginationWrappers( const pathDict = pathArgs .map(({ param, go, type }) => `${JSON.stringify(param.name)}: ${goQueryFormat(go, type)}`) .join(', '); - writer.line( + printer.line( `requestURL := buildURL(c.config.ServerURL, op.Path, map[string]string{${pathDict}})` ); - writer.line( + printer.line( 'resp, err := send(ctx, &c.config, requestSpec{OperationID: op.ID, Method: op.Method, URL: requestURL, Headers: authHeaders, Query: query})' ); - writer.block( + printer.block( 'if err != nil {', () => { - writer.line('return nil, nil, err'); + printer.line('return nil, nil, err'); }, '}' ); - writer.block( + printer.block( 'if resp.StatusCode >= 400 {', () => { - writer.line('return nil, resp, apiErrorFrom(resp, requestURL)'); + printer.line('return nil, resp, apiErrorFrom(resp, requestURL)'); }, '}' ); - writer.line('var raw any'); - writer.block( + printer.line('var raw any'); + printer.block( 'if err := decodeJSON(resp, &raw); err != nil {', () => { - writer.line('return nil, resp, err'); + printer.line('return nil, resp, err'); }, '}' ); - writer.line('return raw, resp, nil'); + printer.line('return raw, resp, nil'); }, '}' ); - writer.line('pages := iterPages(call, *op.Pagination, base)'); + printer.line('pages := iterPages(call, *op.Pagination, base)'); }; - writer.line( + printer.line( `// ${ident}Pages iterates ${ident} response pages; use with \`for page, err := range\`.` ); - writer.block( + printer.block( `func (c *Client) ${ident}Pages(${args}) func(yield func(${pageType}, error) bool) {`, () => { writeCallClosure(); - writer.block( + printer.block( `return func(yield func(${pageType}, error) bool) {`, () => { - writer.block( + printer.block( 'pages(func(raw any, err error) bool {', () => { - writer.line(`var page ${pageType}`); - writer.block( + printer.line(`var page ${pageType}`); + printer.block( 'if err == nil {', () => { - writer.line('err = reencode(raw, &page)'); + printer.line('err = reencode(raw, &page)'); }, '}' ); - writer.line('return yield(page, err)'); + printer.line('return yield(page, err)'); }, '})' ); @@ -605,50 +605,50 @@ function writeGoPaginationWrappers( }, '}' ); - writer.blank(); + printer.blank(); - writer.line(`// ${ident}Items iterates the items of every ${ident} page.`); - writer.block( + 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(); - writer.block( + printer.block( `return func(yield func(${itemType}, error) bool) {`, () => { - writer.block( + printer.block( 'pages(func(raw any, err error) bool {', () => { - writer.block( + printer.block( 'if err != nil {', () => { - writer.line(`var zero ${itemType}`); - writer.line('return yield(zero, err)'); + printer.line(`var zero ${itemType}`); + printer.line('return yield(zero, err)'); }, '}' ); - writer.line('pageItems, _ := resolvePointer(raw, op.Pagination.Items).([]any)'); - writer.block( + printer.line('pageItems, _ := resolvePointer(raw, op.Pagination.Items).([]any)'); + printer.block( 'for _, item := range pageItems {', () => { - writer.line(`var typed ${itemType}`); - writer.block( + printer.line(`var typed ${itemType}`); + printer.block( 'if err := reencode(item, &typed); err != nil {', () => { - writer.line('return yield(typed, err)'); + printer.line('return yield(typed, err)'); }, '}' ); - writer.block( + printer.block( 'if !yield(typed, nil) {', () => { - writer.line('return false'); + printer.line('return false'); }, '}' ); }, '}' ); - writer.line('return true'); + printer.line('return true'); }, '})' ); @@ -658,7 +658,7 @@ function writeGoPaginationWrappers( }, '}' ); - writer.blank(); + printer.blank(); } /** The server URL as a Go expression: literals concatenated with declared-variable params. */ @@ -686,7 +686,7 @@ function serverUrlExpression(server: ServerModel): string { } /** One `URL` function per declared server; server variables become parameters. */ -function writeGoServers(writer: Printer, model: ApiModel): void { +function writeGoServers(printer: Printer, model: ApiModel): void { const servers = model.servers ?? []; if (servers.length === 0) return; const usedNames = new Set(); @@ -703,38 +703,38 @@ function writeGoServers(writer: Printer, model: ApiModel): void { `${identifierFor(variable.name, { style: 'camel', reserved: GO })} default: ${JSON.stringify(variable.default)}` ) .join(', '); - writer.line( + printer.line( `// ${name} returns the ${JSON.stringify(server.description ?? server.url)} base URL${defaults === '' ? '.' : ` (${defaults}).`}` ); - writer.block( + printer.block( `func ${name}(${params.join(', ')}) string {`, () => { - writer.line(`return ${serverUrlExpression(server)}`); + printer.line(`return ${serverUrlExpression(server)}`); }, '}' ); - writer.blank(); + printer.blank(); }); } /** The whole generated file: models + embedded runtime + operations table + Client. */ export const goGenerator: Generator = ({ model, outputPath, emit }) => { - const writer = new Printer('\t'); + const printer = new Printer('\t'); 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); } - writer.line( + printer.line( `// Code generated by @redocly/client-generator (go) from "${model.title}" ${model.version}. DO NOT EDIT.` ); - writer.line( + printer.line( '// Regenerate with `redocly generate-client`. Standard library only — zero dependencies.' ); - writer.line('package client'); - writer.blank(); + printer.line('package client'); + printer.blank(); // One merged import block: the runtime uses every entry; generated code uses a subset. - writer.block( + printer.block( 'import (', () => { for (const spec of [ @@ -753,33 +753,33 @@ export const goGenerator: Generator = ({ model, outputPath, emit }) => { 'strings', 'time', ]) { - writer.line(JSON.stringify(spec)); + printer.line(JSON.stringify(spec)); } }, ')' ); - writer.blank(); + printer.blank(); - writer.line(stripHeader(renderGoModels(model))); - writer.blank(); - writeGoServers(writer, model); - writer.line('// ─── Embedded runtime (@redocly/client-generator go runtime) ───'); - writer.line(stripHeader(GO_RUNTIME_SOURCE)); - writer.blank(); + printer.line(stripHeader(renderGoModels(model))); + printer.blank(); + writeGoServers(printer, model); + printer.line('// ─── Embedded runtime (@redocly/client-generator go runtime) ───'); + printer.line(stripHeader(GO_RUNTIME_SOURCE)); + printer.blank(); - writer.block( + printer.block( 'type operationMeta struct {', () => { - writer.line('ID string'); - writer.line('Method string'); - writer.line('Path string'); - writer.line('Security [][]SecuritySpec'); - writer.line('Pagination *PaginationSpec'); + printer.line('ID string'); + printer.line('Method string'); + printer.line('Path string'); + printer.line('Security [][]SecuritySpec'); + printer.line('Pagination *PaginationSpec'); }, '}' ); - writer.blank(); - writer.block( + printer.blank(); + printer.block( 'var operations = map[string]operationMeta{', () => { for (const { op, ident } of goOperationIdents(model)) { @@ -793,58 +793,58 @@ export const goGenerator: Generator = ({ model, outputPath, emit }) => { ...(security !== undefined ? [`Security: ${security}`] : []), ...(rule !== undefined ? [`Pagination: ${goPaginationLiteral(rule)}`] : []), ]; - writer.line(`${JSON.stringify(id)}: {${fields.join(', ')}},`); + printer.line(`${JSON.stringify(id)}: {${fields.join(', ')}},`); } }, '}' ); - writer.blank(); + printer.blank(); // Per-operation query-parameter structs (pointer fields: absent = not sent). for (const { op, ident } of goOperationIdents(model)) { if (op.queryParams.length === 0) continue; - writer.block( + printer.block( `type ${ident}Params struct {`, () => { for (const param of op.queryParams) { const fieldType = goType(param.schema); - writer.line( + printer.line( `${exported(param.name)} ${fieldType.startsWith('*') ? fieldType : `*${fieldType}`}` ); } }, '}' ); - writer.blank(); + printer.blank(); } - writeDocComment(writer, 'Client', `Client for ${model.title} (${model.version}).`); - writer.block( + writeDocComment(printer, 'Client', `Client for ${model.title} (${model.version}).`); + printer.block( 'type Client struct {', () => { - writer.line('config Config'); + printer.line('config Config'); }, '}' ); - writer.blank(); - writer.block( + printer.blank(); + printer.block( 'func New(config Config) *Client {', () => { - writer.block( + printer.block( 'if config.ServerURL == "" {', () => { - writer.line(`config.ServerURL = ${JSON.stringify(model.serverUrl ?? '')}`); + printer.line(`config.ServerURL = ${JSON.stringify(model.serverUrl ?? '')}`); }, '}' ); - writer.line('return &Client{config: config}'); + printer.line('return &Client{config: config}'); }, '}' ); - writer.blank(); + printer.blank(); for (const { op, ident } of goOperationIdents(model)) { - writeGoMethod(writer, op, ident); + writeGoMethod(printer, op, ident); const rule = paginationRules.get(ident); if (rule === undefined) continue; const success = successSchema(op); @@ -857,7 +857,7 @@ export const goGenerator: Generator = ({ model, outputPath, emit }) => { : undefined; const element = itemsArray?.kind === 'array' ? itemsArray.items : undefined; writeGoPaginationWrappers( - writer, + printer, op, ident, pageType, @@ -865,7 +865,7 @@ export const goGenerator: Generator = ({ model, outputPath, emit }) => { ); } - return [{ path: outputPath.replace(/\.[^.\\/]+$/, '.go'), content: writer.toString() }]; + return [{ path: outputPath.replace(/\.[^.\\/]+$/, '.go'), content: printer.toString() }]; }; /** One idiomatic Go call per operation — feeds `x-codeSamples` for docs. */ diff --git a/packages/client-generator/src/generators/php/index.ts b/packages/client-generator/src/generators/php/index.ts index 797522c132..c31ea23f75 100644 --- a/packages/client-generator/src/generators/php/index.ts +++ b/packages/client-generator/src/generators/php/index.ts @@ -171,14 +171,14 @@ function serialization(schema: SchemaModel, expr: string, model: ApiModel): stri return undefined; } -function writeDocComment(writer: Printer, name: string, description?: string): void { +function writeDocComment(printer: Printer, name: string, description?: string): void { const lines = docText(description); if (lines.length === 0) return; - writer.line(`/** ${name} — ${lines.join(' ')} */`); + printer.line(`/** ${name} — ${lines.join(' ')} */`); } function writeClass( - writer: Printer, + printer: Printer, name: string, properties: PropertyModel[], model: ApiModel, @@ -189,34 +189,34 @@ function writeClass( ...properties.filter((property) => property.required), ...properties.filter((property) => !property.required), ]; - writeDocComment(writer, className(name), description); - writer.block(`final class ${className(name)}`, () => {}, ''); - writer.block( + writeDocComment(printer, className(name), description); + printer.block(`final class ${className(name)}`, () => {}, ''); + printer.block( '{', () => { - writer.block( + printer.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)},`); + printer.line(`public ${type} ${'$'}${propertyName(property.name)},`); } else { const nullable = type === 'mixed' || type.startsWith('?') ? type : `?${type}`; - writer.line(`public ${nullable} ${'$'}${propertyName(property.name)} = null,`); + printer.line(`public ${nullable} ${'$'}${propertyName(property.name)} = null,`); } } }, ') {' ); - writer.line('}'); - writer.blank(); + printer.line('}'); + printer.blank(); - writer.block('public static function fromArray(array $data): self', () => {}, ''); - writer.block( + printer.block('public static function fromArray(array $data): self', () => {}, ''); + printer.block( '{', () => { - writer.block( + printer.block( 'return new self(', () => { for (const property of ordered) { @@ -224,11 +224,11 @@ function writeClass( const typed = hydration(property.schema, raw, model); const php = propertyName(property.name); if (property.required) { - writer.line(`${php}: ${typed ?? raw},`); + printer.line(`${php}: ${typed ?? raw},`); } else if (typed === undefined) { - writer.line(`${php}: ${raw} ?? null,`); + printer.line(`${php}: ${raw} ?? null,`); } else { - writer.line(`${php}: isset(${raw}) ? ${typed} : null,`); + printer.line(`${php}: isset(${raw}) ? ${typed} : null,`); } } }, @@ -237,65 +237,65 @@ function writeClass( }, '}' ); - writer.blank(); + printer.blank(); - writer.block('public function toArray(): array', () => {}, ''); - writer.block( + printer.block('public function toArray(): array', () => {}, ''); + printer.block( '{', () => { - writer.line('$data = [];'); + printer.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};`); + printer.line(`$data[${phpString(property.name)}] = ${wire};`); } else { - writer.block( + printer.block( `if (${value} !== null) {`, () => { - writer.line(`$data[${phpString(property.name)}] = ${wire};`); + printer.line(`$data[${phpString(property.name)}] = ${wire};`); }, '}' ); } } - writer.line('return $data;'); + printer.line('return $data;'); }, '}' ); }, '}' ); - writer.blank(); + printer.blank(); } /** Render every named schema: classes (allOf flattened), native enums, union dispatchers. */ export function renderPhpModels(model: ApiModel): string { - const writer = new Printer(' '); + const printer = new Printer(' '); for (const { name, schema } of model.schemas) { const asEnum = enumValues(schema); if (asEnum !== undefined && (asEnum.scalar === 'string' || asEnum.scalar === 'integer')) { const backing = asEnum.scalar === 'string' ? 'string' : 'int'; - writeDocComment(writer, className(name), schema.description); - writer.block(`enum ${className(name)}: ${backing}`, () => {}, ''); - writer.block( + writeDocComment(printer, className(name), schema.description); + printer.block(`enum ${className(name)}: ${backing}`, () => {}, ''); + printer.block( '{', () => { asEnum.values.forEach((value) => { const member = identifierFor(String(value), { style: 'pascal', reserved: PHP }); const literal = typeof value === 'string' ? phpString(value) : String(value); - writer.line(`case ${member} = ${literal};`); + printer.line(`case ${member} = ${literal};`); }); }, '}' ); - writer.blank(); + printer.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); + writeClass(printer, name, flat.properties, model, flat.description ?? schema.description); continue; } } @@ -305,35 +305,35 @@ export function renderPhpModels(model: ApiModel): string { const table = cases.cases .map((entry) => `${entry.value} -> ${className(entry.schemaName)}`) .join(', '); - writer.line( + printer.line( `/** ${typeName} is a discriminated union (${phpString(cases.property)}): ${table}. */` ); - writer.block(`function unmarshal${typeName}(array $data): mixed`, () => {}, ''); - writer.block( + printer.block(`function unmarshal${typeName}(array $data): mixed`, () => {}, ''); + printer.block( '{', () => { - writer.block( + printer.block( `return match ($data[${phpString(cases.property)}] ?? null) {`, () => { for (const entry of cases.cases) { - writer.line( + printer.line( `${phpString(entry.value)} => ${className(entry.schemaName)}::fromArray($data),` ); } - writer.line('default => $data,'); + printer.line('default => $data,'); }, '};' ); }, '}' ); - writer.blank(); + printer.blank(); continue; } // Everything else (plain unions, aliases, records) has no PHP declaration; // references resolve to the underlying type via phpType. } - return writer.toString(); + return printer.toString(); } /** The op's primary JSON success schema, or undefined for void/no-body ops. */ @@ -436,16 +436,16 @@ function methodArgs(op: OperationModel, model: ApiModel, includeBody: boolean): } /** The shared prologue: resolve auth, build query/url, merge headers. */ -function writeRequestSetup(writer: Printer, op: OperationModel, args: MethodArgs): void { - writer.line(`$op = OPERATIONS[${phpString(op.specName ?? op.name)}];`); - writer.line( +function writeRequestSetup(printer: Printer, op: OperationModel, args: MethodArgs): void { + printer.line(`$op = OPERATIONS[${phpString(op.specName ?? op.name)}];`); + printer.line( "[$authHeaders, $query, $cookies] = resolveAuth($op['security'] ?? [], $this->config->auth);" ); for (const { php, wire } of args.queryArgs) { - writer.block( + printer.block( `if (${'$'}${php} !== null) {`, () => { - writer.line(`$query[${phpString(wire)}] = ${'$'}${php};`); + printer.line(`$query[${phpString(wire)}] = ${'$'}${php};`); }, '}' ); @@ -453,18 +453,18 @@ function writeRequestSetup(writer: Printer, op: OperationModel, args: MethodArgs 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( + printer.line(`$url = buildUrl($this->config->serverUrl, $op['path'], [${pathDict}]);`); + printer.line('$requestHeaders = array_merge($authHeaders, $headers ?? []);'); + printer.block( 'if ($cookies !== []) {', () => { - writer.line("$requestHeaders['Cookie'] = implode('; ', $cookies);"); + printer.line("$requestHeaders['Cookie'] = implode('; ', $cookies);"); }, '}' ); } -function writePhpMethod(writer: Printer, op: OperationModel, model: ApiModel): void { +function writePhpMethod(printer: Printer, op: OperationModel, model: ApiModel): void { const args = methodArgs(op, model, true); const sse = sseResponse(op); const success = successSchema(op); @@ -481,37 +481,37 @@ function writePhpMethod(writer: Printer, op: OperationModel, model: ApiModel): v : rawBody ? 'string' : 'void'; - writeDocComment(writer, methodName(op), op.summary ?? `${op.method.toUpperCase()} ${op.path}`); - writer.block( + writeDocComment(printer, methodName(op), op.summary ?? `${op.method.toUpperCase()} ${op.path}`); + printer.block( `public function ${methodName(op)}(${args.signature.join(', ')}): ${returnType}`, () => {}, '' ); - writer.block( + printer.block( '{', () => { - writeRequestSetup(writer, op, args); + writeRequestSetup(printer, op, args); if (sse !== undefined) { const jsonData = sse.schema !== undefined && sse.schema.kind !== 'unknown'; - writer.line('$url = appendQuery($url, $query);'); - writer.block( + printer.line('$url = appendQuery($url, $query);'); + printer.block( '$open = function (array $extraHeaders) use ($url, $requestHeaders): \\CurlHandle {', () => { - writer.line('$handle = curl_init($url);'); - writer.line('$lines = [];'); - writer.block( + printer.line('$handle = curl_init($url);'); + printer.line('$lines = [];'); + printer.block( 'foreach (array_merge($requestHeaders, $extraHeaders) as $name => $value) {', () => { - writer.line("$lines[] = $name . ': ' . $value;"); + printer.line("$lines[] = $name . ': ' . $value;"); }, '}' ); - writer.line('curl_setopt($handle, CURLOPT_HTTPHEADER, $lines);'); - writer.line('return $handle;'); + printer.line('curl_setopt($handle, CURLOPT_HTTPHEADER, $lines);'); + printer.line('return $handle;'); }, '};' ); - writer.line(`yield from iterSse($open, ${jsonData ? 'true' : 'false'});`); + printer.line(`yield from iterSse($open, ${jsonData ? 'true' : 'false'});`); return; } const request = [ @@ -522,11 +522,11 @@ function writePhpMethod(writer: Printer, op: OperationModel, model: ApiModel): v `'query' => $query`, ]; if (op.requestBody && isMultipart(op)) { - writer.line('[$contentType, $encoded] = toMultipart($body);'); + printer.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});`); + printer.line(`$payload = json_encode(${wire});`); request.push( `'body' => $payload`, `'contentType' => ${phpString(op.requestBody.contentType)}` @@ -535,34 +535,34 @@ function writePhpMethod(writer: Printer, op: OperationModel, model: ApiModel): v if (MUTATING.has(op.method.toLowerCase()) && op.requestBody) { request.push(`'idempotencyKey' => $idempotencyKey`); } - writer.line(`$response = send($this->config, [${request.join(', ')}]);`); - writer.block( + printer.line(`$response = send($this->config, [${request.join(', ')}]);`); + printer.block( "if ($response['status'] >= 400) {", () => { - writer.line('throw apiErrorFrom($response);'); + printer.line('throw apiErrorFrom($response);'); }, '}' ); if (rawBody) { - writer.line("return $response['body'];"); + printer.line("return $response['body'];"); return; } if (returnType === 'void') { - writer.line('decodeJson($response);'); + printer.line('decodeJson($response);'); return; } const typed = success === undefined ? undefined : hydration(success, 'decodeJson($response)', model); - writer.line(`return ${typed ?? 'decodeJson($response)'};`); + printer.line(`return ${typed ?? 'decodeJson($response)'};`); }, '}' ); - writer.blank(); + printer.blank(); } /** `Pages()` / `Items()` generators over the runtime's iterPages. */ function writePhpPaginationWrappers( - writer: Printer, + printer: Printer, op: OperationModel, model: ApiModel, pageHydration: string | undefined, @@ -573,13 +573,13 @@ function writePhpPaginationWrappers( const name = methodName(op); const writeCall = () => { - writer.line(`$op = OPERATIONS[${phpString(op.specName ?? op.name)}];`); - writer.line('$base = [];'); + printer.line(`$op = OPERATIONS[${phpString(op.specName ?? op.name)}];`); + printer.line('$base = [];'); for (const { php, wire } of args.queryArgs) { - writer.block( + printer.block( `if (${'$'}${php} !== null) {`, () => { - writer.line(`$base[${phpString(wire)}] = ${'$'}${php};`); + printer.line(`$base[${phpString(wire)}] = ${'$'}${php};`); }, '}' ); @@ -587,77 +587,77 @@ function writePhpPaginationWrappers( const pathDict = args.pathArgs .map(({ php, wire }) => `${phpString(wire)} => ${'$'}${php}`) .join(', '); - writer.block( + printer.block( '$call = function (array $params) use ($op, $headers): array {', () => { - writer.line( + printer.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( + printer.line(`$url = buildUrl($this->config->serverUrl, $op['path'], [${pathDict}]);`); + printer.line('$requestHeaders = array_merge($authHeaders, $headers ?? []);'); + printer.block( 'if ($cookies !== []) {', () => { - writer.line("$requestHeaders['Cookie'] = implode('; ', $cookies);"); + printer.line("$requestHeaders['Cookie'] = implode('; ', $cookies);"); }, '}' ); - writer.line( + printer.line( "$response = send($this->config, ['operationId' => $op['id'], 'method' => $op['method'], 'url' => $url, 'headers' => $requestHeaders, 'query' => array_merge($params, $authQuery)]);" ); - writer.block( + printer.block( "if ($response['status'] >= 400) {", () => { - writer.line('throw apiErrorFrom($response);'); + printer.line('throw apiErrorFrom($response);'); }, '}' ); - writer.line('return [decodeJson($response), $response];'); + printer.line('return [decodeJson($response), $response];'); }, '};' ); }; - writer.line(`/** ${name} response pages, following the pagination rule automatically. */`); - writer.block( + printer.line(`/** ${name} response pages, following the pagination rule automatically. */`); + printer.block( `public function ${name}Pages(${args.signature.join(', ')}): \\Generator`, () => {}, '' ); - writer.block( + printer.block( '{', () => { writeCall(); - writer.block( + printer.block( "foreach (iterPages($call, $op['pagination'], $base) as $page) {", () => { - writer.line(`yield ${pageHydration ?? '$page'};`); + printer.line(`yield ${pageHydration ?? '$page'};`); }, '}' ); }, '}' ); - writer.blank(); + printer.blank(); - writer.line(`/** The items of every ${name} page. */`); - writer.block( + printer.line(`/** The items of every ${name} page. */`); + printer.block( `public function ${name}Items(${args.signature.join(', ')}): \\Generator`, () => {}, '' ); - writer.block( + printer.block( '{', () => { writeCall(); - writer.block( + printer.block( "foreach (iterPages($call, $op['pagination'], $base) as $page) {", () => { - writer.line(`$items = resolvePointer($page, ${phpString(itemsPointer ?? '')});`); - writer.block( + printer.line(`$items = resolvePointer($page, ${phpString(itemsPointer ?? '')});`); + printer.block( 'foreach (is_array($items) ? $items : [] as $item) {', () => { - writer.line(`yield ${itemHydration ?? '$item'};`); + printer.line(`yield ${itemHydration ?? '$item'};`); }, '}' ); @@ -667,7 +667,7 @@ function writePhpPaginationWrappers( }, '}' ); - writer.blank(); + printer.blank(); } /** The server URL as a PHP expression: literals concatenated with declared-variable arguments. */ @@ -695,13 +695,15 @@ function serverUrlExpression(server: ServerModel): string { } /** One static method per declared server; server variables become named string arguments. */ -function writeServers(writer: Printer, model: ApiModel): void { +function writeServers(printer: Printer, model: ApiModel): void { const servers = model.servers ?? []; if (servers.length === 0) return; const usedNames = new Set(); - writer.line('/** The declared servers; variables default to the values from the description. */'); - writer.block('final class Servers', () => {}, ''); - writer.block( + printer.line( + '/** The declared servers; variables default to the values from the description. */' + ); + printer.block('final class Servers', () => {}, ''); + printer.block( '{', () => { servers.forEach((server, index) => { @@ -715,12 +717,12 @@ function writeServers(writer: Printer, model: ApiModel): void { (variable) => `string ${'$'}${propertyName(variable.name)} = ${phpString(variable.default)}` ); - if (index > 0) writer.blank(); - writer.block(`public static function ${name}(${params.join(', ')}): string`, () => {}, ''); - writer.block( + if (index > 0) printer.blank(); + printer.block(`public static function ${name}(${params.join(', ')}): string`, () => {}, ''); + printer.block( '{', () => { - writer.line(`return ${serverUrlExpression(server)};`); + printer.line(`return ${serverUrlExpression(server)};`); }, '}' ); @@ -728,7 +730,7 @@ function writeServers(writer: Printer, model: ApiModel): void { }, '}' ); - writer.blank(); + printer.blank(); } /** Drop the standalone header ( { - const writer = new Printer(' '); + const printer = new Printer(' '); const namespace = identifierFor(model.title, { style: 'pascal', reserved: PHP }); - writer.line('= 8.1, curl extension — zero Composer dependencies.' ); - writer.blank(); - writer.line('declare(strict_types=1);'); - writer.blank(); - writer.line(`namespace ${namespace};`); - writer.blank(); - writer.line(renderPhpModels(model)); - writeServers(writer, model); - writer.line('// ─── Embedded runtime (@redocly/client-generator php runtime) ───'); - writer.line(stripPhpHeader(PHP_RUNTIME_SOURCE)); - writer.blank(); + printer.blank(); + printer.line('declare(strict_types=1);'); + printer.blank(); + printer.line(`namespace ${namespace};`); + printer.blank(); + printer.line(renderPhpModels(model)); + writeServers(printer, model); + printer.line('// ─── Embedded runtime (@redocly/client-generator php runtime) ───'); + printer.line(stripPhpHeader(PHP_RUNTIME_SOURCE)); + printer.blank(); const operations = model.services.flatMap((service) => service.operations); const paginationRules = new Map(); @@ -775,7 +777,7 @@ export const phpGenerator: Generator = ({ model, outputPath, emit }) => { if (rule !== undefined) paginationRules.set(op.name, rule); } - writer.block( + printer.block( 'const OPERATIONS = [', () => { for (const op of operations) { @@ -789,37 +791,37 @@ export const phpGenerator: Generator = ({ model, outputPath, emit }) => { ...(security !== undefined ? [`'security' => ${security}`] : []), ...(rule !== undefined ? [`'pagination' => ${phpPaginationLiteral(rule)}`] : []), ]; - writer.line(`${phpString(id)} => [${fields.join(', ')}],`); + printer.line(`${phpString(id)} => [${fields.join(', ')}],`); } }, '];' ); - writer.blank(); + printer.blank(); - writeDocComment(writer, 'Client', `Client for ${model.title} (${model.version}).`); + writeDocComment(printer, 'Client', `Client for ${model.title} (${model.version}).`); // Not final: PHP test suites mock concrete classes (createMock(Client::class)). - writer.block('class Client', () => {}, ''); - writer.block( + printer.block('class Client', () => {}, ''); + printer.block( '{', () => { - writer.block('public function __construct(private Config $config)', () => {}, ''); - writer.block( + printer.block('public function __construct(private Config $config)', () => {}, ''); + printer.block( '{', () => { - writer.block( + printer.block( "if ($this->config->serverUrl === '') {", () => { - writer.line(`$this->config->serverUrl = ${phpString(model.serverUrl ?? '')};`); + printer.line(`$this->config->serverUrl = ${phpString(model.serverUrl ?? '')};`); }, '}' ); }, '}' ); - writer.blank(); + printer.blank(); for (const op of operations) { - writePhpMethod(writer, op, model); + writePhpMethod(printer, op, model); const rule = paginationRules.get(op.name); if (rule === undefined) continue; const success = successSchema(op); @@ -834,13 +836,13 @@ export const phpGenerator: Generator = ({ model, outputPath, emit }) => { 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); + writePhpPaginationWrappers(printer, op, model, pageHydration, itemHydration, rule.items); } }, '}' ); - return [{ path: outputPath.replace(/\.[^.\\/]+$/, '.php'), content: writer.toString() }]; + return [{ path: outputPath.replace(/\.[^.\\/]+$/, '.php'), content: printer.toString() }]; }; /** One idiomatic PHP call per operation — feeds `x-codeSamples` for docs. */ diff --git a/packages/client-generator/src/generators/python/index.ts b/packages/client-generator/src/generators/python/index.ts index 905de64a05..f9b78e2654 100644 --- a/packages/client-generator/src/generators/python/index.ts +++ b/packages/client-generator/src/generators/python/index.ts @@ -73,88 +73,88 @@ export function pythonType(schema: SchemaModel): string { } } -function writeDocstring(writer: Printer, description?: string): void { +function writeDocstring(printer: Printer, description?: string): void { const lines = docText(description); if (lines.length === 0) return; if (lines.length === 1) { - writer.line(`"""${lines[0]}"""`); + printer.line(`"""${lines[0]}"""`); return; } - writer.line(`"""${lines[0]}`); - for (const line of lines.slice(1)) writer.line(line); - writer.line('"""'); + printer.line(`"""${lines[0]}`); + for (const line of lines.slice(1)) printer.line(line); + printer.line('"""'); } function writeDataclass( - writer: Printer, + printer: Printer, name: string, properties: PropertyModel[], description?: string ): void { - writer.line('@dataclass'); - writer.block(`class ${className(name)}:`, () => { - writeDocstring(writer, description); + printer.line('@dataclass'); + printer.block(`class ${className(name)}:`, () => { + writeDocstring(printer, description); // 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) writer.line('pass'); + if (ordered.length === 0) printer.line('pass'); for (const property of ordered) { const { python, renamed } = fieldName(property.name); if (renamed) fieldMap.push([python, property.name]); const baseType = pythonType(property.schema); if (property.required) { - writer.line(`${python}: ${baseType}`); + printer.line(`${python}: ${baseType}`); } else { const optional = baseType.startsWith('Optional[') ? baseType : `Optional[${baseType}]`; - writer.line(`${python}: ${optional} = None`); + printer.line(`${python}: ${optional} = None`); } } if (fieldMap.length > 0) { - writer.blank(); - writer.line('# Python field name -> wire (JSON) name, for (de)serialization.'); + printer.blank(); + printer.line('# Python field name -> wire (JSON) name, for (de)serialization.'); const entries = fieldMap.map(([py, wire]) => `"${py}": ${JSON.stringify(wire)}`).join(', '); - writer.line(`_field_map: ClassVar[Dict[str, str]] = {${entries}}`); + printer.line(`_field_map: ClassVar[Dict[str, str]] = {${entries}}`); } }); - writer.blank(); - writer.blank(); + printer.blank(); + printer.blank(); } /** Render every named schema: Enum classes, dataclasses (allOf flattened), union aliases. */ export function renderPythonModels(model: ApiModel): string { - const writer = new Printer(' '); - writer.line('from __future__ import annotations'); - writer.blank(); - writer.line('from dataclasses import dataclass'); - writer.line('from enum import Enum'); - writer.line( + const printer = new Printer(' '); + printer.line('from __future__ import annotations'); + printer.blank(); + printer.line('from dataclasses import dataclass'); + printer.line('from enum import Enum'); + printer.line( 'from typing import Any, AsyncIterator, ClassVar, Dict, Iterator, List, Literal, Optional, Tuple, Union' ); - writer.blank(); - writer.blank(); + 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'; - writer.block(`class ${className(name)}(${base}):`, () => { - writeDocstring(writer, schema.description); + printer.block(`class ${className(name)}(${base}):`, () => { + writeDocstring(printer, schema.description); asEnum.values.forEach((value, index) => { - writer.line(`${asEnum.memberNames[index]} = ${JSON.stringify(value)}`); + printer.line(`${asEnum.memberNames[index]} = ${JSON.stringify(value)}`); }); }); - writer.blank(); - writer.blank(); + printer.blank(); + printer.blank(); continue; } if (schema.kind === 'object' || schema.kind === 'intersection') { const flat = flattenAllOf(schema, model); if (flat !== undefined) { - writeDataclass(writer, name, flat.properties, flat.description ?? schema.description); + writeDataclass(printer, name, flat.properties, flat.description ?? schema.description); continue; } } @@ -166,14 +166,14 @@ export function renderPythonModels(model: ApiModel): string { const table = cases.cases .map((entry) => `${entry.value} -> ${className(entry.schemaName)}`) .join(', '); - writer.line(`# Discriminated by "${cases.property}": ${table}`); + printer.line(`# Discriminated by "${cases.property}": ${table}`); } - writer.line(`${className(name)} = ${pythonType(schema)}`); - writer.blank(); + printer.line(`${className(name)} = ${pythonType(schema)}`); + printer.blank(); }); } for (const emit of aliases) emit(); - return writer.toString(); + return printer.toString(); } /** The server URL as a Python expression: literals concatenated with declared-variable args. */ @@ -201,15 +201,15 @@ function serverUrlExpression(server: ServerModel): string { } /** One static method per declared server; server variables become keyword arguments. */ -function writePythonServers(writer: Printer, model: ApiModel): void { +function writePythonServers(printer: Printer, model: ApiModel): void { const servers = model.servers ?? []; if (servers.length === 0) return; const usedNames = new Set(); - writer.block('class Servers:', () => { - writer.line( + printer.block('class Servers:', () => { + printer.line( '"""The declared servers; variables default to the values from the description."""' ); - writer.blank(); + printer.blank(); servers.forEach((server, index) => { let name = identifierFor(server.description ?? `server${index + 1}`, { style: 'snake', @@ -221,14 +221,14 @@ function writePythonServers(writer: Printer, model: ApiModel): void { (variable) => `${fieldName(variable.name).python}: str = ${JSON.stringify(variable.default)}` ); - if (index > 0) writer.blank(); - writer.line('@staticmethod'); - writer.block(`def ${name}(${params.join(', ')}) -> str:`, () => { - writer.line(`return ${serverUrlExpression(server)}`); + if (index > 0) printer.blank(); + printer.line('@staticmethod'); + printer.block(`def ${name}(${params.join(', ')}) -> str:`, () => { + printer.line(`return ${serverUrlExpression(server)}`); }); }); }); - writer.blank(); + printer.blank(); } /** `DISCRIMINATORS[Pet] = ("petType", {"cat": Cat, ...})` registration lines. */ @@ -333,7 +333,7 @@ function paginationSpec( } function writeMethod( - writer: Printer, + printer: Printer, op: OperationModel, ident: string, errorMode: 'throw' | 'result', @@ -376,38 +376,38 @@ function writeMethod( const awaitKw = isAsync ? 'await ' : ''; const sendFn = isAsync ? 'send_async' : 'send'; const signature = ['self', ...positional, ...bodyArg, '*', ...kwargs].join(', '); - writer.block(`${prefix} ${ident}(${signature}) -> ${returns}:`, () => { - writeDocstring(writer, op.summary); - writer.line(`op = _OPERATIONS["${ident}"]`); - writer.line('auth_headers, auth_query = resolve_auth(op.get("security") or [], self._auth)'); - writer.line('params: Dict[str, Any] = dict(auth_query)'); + printer.block(`${prefix} ${ident}(${signature}) -> ${returns}:`, () => { + writeDocstring(printer, 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) { - writer.block(`if ${python} is not None:`, () => { - writer.line(`params[${JSON.stringify(param.name)}] = encode(${python})`); + printer.block(`if ${python} is not None:`, () => { + printer.line(`params[${JSON.stringify(param.name)}] = encode(${python})`); }); } const pathDict = pathArgs .map(({ param, python }) => `${JSON.stringify(param.name)}: ${python}`) .join(', '); - writer.line(`url = build_url(self._server_url, op["path"], {${pathDict}})`); + 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'; - writer.block('def _open(extra_headers: Dict[str, str]):', () => { - writer.line( + 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)' ); }); - writer.line(`return ${isAsync ? 'aiter_sse' : 'iter_sse'}(_open, data_kind="${dataKind}")`); + printer.line(`return ${isAsync ? 'aiter_sse' : 'iter_sse'}(_open, data_kind="${dataKind}")`); return; } - if (isMultipart(op)) writer.line('form_data, form_files = to_multipart(body)'); + if (isMultipart(op)) printer.line('form_data, form_files = to_multipart(body)'); const bodyKw = op.requestBody ? isMultipart(op) ? ', data=form_data, files=form_files' : ', json_body=encode(body)' : ''; - writer.line( + 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)' @@ -415,25 +415,25 @@ function writeMethod( const decoded = success === undefined ? 'None' : `decode(${pythonType(success)}, _safe_json(response))`; if (errorMode === 'result') { - writer.block('if not response.is_success:', () => { - writer.line('return Result(data=None, error=_safe_json(response), response=response)'); + printer.block('if not response.is_success:', () => { + printer.line('return Result(data=None, error=_safe_json(response), response=response)'); }); - writer.line(`return Result(data=${decoded}, error=None, response=response)`); + printer.line(`return Result(data=${decoded}, error=None, response=response)`); } else { - writer.block('if not response.is_success:', () => { - writer.line( + printer.block('if not response.is_success:', () => { + printer.line( 'raise ApiError(url, response.status_code, response.reason_phrase, _safe_json(response))' ); }); - writer.line(success === undefined ? 'return None' : `return ${decoded}`); + printer.line(success === undefined ? 'return None' : `return ${decoded}`); } }); - writer.blank(); + printer.blank(); } /** `_pages` / `_items` iterator methods for a paginated operation. */ function writePaginationWrappers( - writer: Printer, + printer: Printer, op: OperationModel, ident: string, isAsync: boolean, @@ -461,74 +461,74 @@ function writePaginationWrappers( const itemsFn = isAsync ? 'aiter_items' : 'iter_items'; const writeCallClosure = () => { - writer.line('base: Dict[str, Any] = {}'); + printer.line('base: Dict[str, Any] = {}'); for (const { param, python } of queryArgs) { - writer.block(`if ${python} is not None:`, () => { - writer.line(`base[${JSON.stringify(param.name)}] = encode(${python})`); + printer.block(`if ${python} is not None:`, () => { + printer.line(`base[${JSON.stringify(param.name)}] = encode(${python})`); }); } const prefix = isAsync ? 'async def' : 'def'; const awaitKw = isAsync ? 'await ' : ''; - writer.block(`${prefix} _page(page_params: Dict[str, Any]) -> Tuple[Any, Any]:`, () => { - writer.line('auth_headers, auth_query = resolve_auth(op.get("security") or [], self._auth)'); - writer.line('url = build_url(self._server_url, op["path"], {})'); - writer.line( + 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)'); + printer.line('url = build_url(self._server_url, op["path"], {})'); + 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)' ); - writer.block('if not response.is_success:', () => { - writer.line( + printer.block('if not response.is_success:', () => { + printer.line( 'raise ApiError(url, response.status_code, response.reason_phrase, _safe_json(response))' ); }); - writer.line('return _safe_json(response), response'); + printer.line('return _safe_json(response), response'); }); }; // pages: raw page JSON decoded into the page model per page. if (isAsync) { - writer.block(`async def ${ident}_pages(${signature}) -> ${iterType}[${pageType}]:`, () => { - writer.line(`op = _OPERATIONS["${ident}"]`); + printer.block(`async def ${ident}_pages(${signature}) -> ${iterType}[${pageType}]:`, () => { + printer.line(`op = _OPERATIONS["${ident}"]`); writeCallClosure(); - writer.block(`async for page in ${pagesFn}(_page, op["pagination"], base):`, () => { - writer.line(pageType === 'Any' ? 'yield page' : `yield decode(${pageType}, page)`); + printer.block(`async for page in ${pagesFn}(_page, op["pagination"], base):`, () => { + printer.line(pageType === 'Any' ? 'yield page' : `yield decode(${pageType}, page)`); }); }); - writer.blank(); - writer.block(`async def ${ident}_items(${signature}) -> ${iterType}[${itemType}]:`, () => { - writer.line(`op = _OPERATIONS["${ident}"]`); + printer.blank(); + printer.block(`async def ${ident}_items(${signature}) -> ${iterType}[${itemType}]:`, () => { + printer.line(`op = _OPERATIONS["${ident}"]`); writeCallClosure(); - writer.block(`async for item in ${itemsFn}(_page, op["pagination"], base):`, () => { - writer.line(itemType === 'Any' ? 'yield item' : `yield decode(${itemType}, item)`); + printer.block(`async for item in ${itemsFn}(_page, op["pagination"], base):`, () => { + printer.line(itemType === 'Any' ? 'yield item' : `yield decode(${itemType}, item)`); }); }); } else { - writer.block(`def ${ident}_pages(${signature}) -> ${iterType}[${pageType}]:`, () => { - writer.line(`op = _OPERATIONS["${ident}"]`); + printer.block(`def ${ident}_pages(${signature}) -> ${iterType}[${pageType}]:`, () => { + printer.line(`op = _OPERATIONS["${ident}"]`); writeCallClosure(); - writer.line( + printer.line( pageType === 'Any' ? `return ${pagesFn}(_page, op["pagination"], base)` : `return (decode(${pageType}, page) for page in ${pagesFn}(_page, op["pagination"], base))` ); }); - writer.blank(); - writer.block(`def ${ident}_items(${signature}) -> ${iterType}[${itemType}]:`, () => { - writer.line(`op = _OPERATIONS["${ident}"]`); + printer.blank(); + printer.block(`def ${ident}_items(${signature}) -> ${iterType}[${itemType}]:`, () => { + printer.line(`op = _OPERATIONS["${ident}"]`); writeCallClosure(); - writer.line( + printer.line( itemType === 'Any' ? `return ${itemsFn}(_page, op["pagination"], base)` : `return (decode(${itemType}, item) for item in ${itemsFn}(_page, op["pagination"], base))` ); }); } - writer.blank(); + printer.blank(); } function writeClientClass( - writer: Printer, + printer: Printer, model: ApiModel, errorMode: 'throw' | 'result', isAsync: boolean, @@ -536,35 +536,35 @@ function writeClientClass( ): void { const name = isAsync ? 'AsyncClient' : 'Client'; const httpType = isAsync ? 'httpx.AsyncClient' : 'httpx.Client'; - writer.block(`class ${name}:`, () => { + printer.block(`class ${name}:`, () => { writeDocstring( - writer, + printer, `${isAsync ? 'Async ' : ''}client for ${model.title} (${model.version}).` ); - writer.block( + printer.block( `def __init__(self, server_url: str = ${JSON.stringify(model.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:`, () => { - writer.line('self._server_url = server_url'); - writer.line('self._auth = auth or {}'); - writer.line('self._config: Dict[str, Any] = {'); - writer.indent(() => { - writer.line('"headers": headers or {},'); - writer.line('"timeout": timeout,'); - writer.line('"retry": retry or {},'); - writer.line('"middleware": middleware or [],'); - writer.line('"idempotency_key": idempotency_key,'); + 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,'); }); - writer.line('}'); - writer.line(`self._http = http_client or ${httpType}()`); + printer.line('}'); + printer.line(`self._http = http_client or ${httpType}()`); } ); - writer.blank(); + printer.blank(); for (const { op, ident } of operationIdents(model)) { - writeMethod(writer, op, ident, errorMode, isAsync); + writeMethod(printer, op, ident, errorMode, isAsync); const spec = paginationSpecs.get(ident); if (spec !== undefined) { const success = successSchema(op); @@ -576,7 +576,7 @@ function writeClientClass( : undefined; const element = itemsArray?.kind === 'array' ? itemsArray.items : undefined; writePaginationWrappers( - writer, + printer, op, ident, isAsync, @@ -585,55 +585,55 @@ function writeClientClass( } } }); - writer.blank(); + printer.blank(); } /** The whole generated file: header, models, embedded runtime, descriptors, clients. */ export const pythonGenerator: Generator = ({ model, outputPath, emit }) => { const errorMode = emit.errorMode ?? 'throw'; - const writer = new Printer(' '); - writer.line( + const printer = new Printer(' '); + printer.line( `# Generated by @redocly/client-generator (python) from "${model.title}" ${model.version}.` ); - writer.line('# Do not edit by hand — regenerate with `redocly generate-client`.'); - writer.line('# Requires Python >= 3.9 and httpx: pip install httpx'); - writer.blank(); + printer.line('# Do not edit by hand — regenerate with `redocly generate-client`.'); + printer.line('# Requires Python >= 3.9 and httpx: pip install httpx'); + printer.blank(); // Models (with the shared imports header). - writer.line(renderPythonModels(model).trimEnd()); - writer.blank(); - writer.blank(); - writePythonServers(writer, model); + printer.line(renderPythonModels(model).trimEnd()); + printer.blank(); + printer.blank(); + writePythonServers(printer, model); // The embedded runtime, stitched into one module: `from __future__` may appear // only at the top of a file, and the intra-runtime relative imports resolve to // this same file — both are dropped; duplicate stdlib imports are legal Python. - writer.line('# ─── Embedded runtime (@redocly/client-generator python runtime) ───'); + 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(); - writer.line(stitched); - writer.blank(); + printer.line(stitched); + printer.blank(); } - writer.blank(); + printer.blank(); const registrations = discriminatorRegistrations(model); if (registrations.length > 0) { - writer.line('# Discriminated unions dispatch by their property inside decode().'); - for (const registration of registrations) writer.line(registration); - writer.blank(); + printer.line('# Discriminated unions dispatch by their property inside decode().'); + for (const registration of registrations) printer.line(registration); + printer.blank(); } - writer.block('def _safe_json(response: httpx.Response) -> Any:', () => { - writer.block('try:', () => { - writer.line('return response.json()'); + printer.block('def _safe_json(response: httpx.Response) -> Any:', () => { + printer.block('try:', () => { + printer.line('return response.json()'); }); - writer.block('except Exception:', () => { - writer.line('return None'); + printer.block('except Exception:', () => { + printer.line('return None'); }); }); - writer.blank(); + printer.blank(); // The wire-shape descriptor table the runtime routes by. const paginationSpecs = new Map | undefined>(); @@ -643,8 +643,8 @@ export const pythonGenerator: Generator = ({ model, outputPath, emit }) => { paginationSpec(op, emit as { pagination?: Record }) ); } - writer.line('_OPERATIONS = {'); - writer.indent(() => { + printer.line('_OPERATIONS = {'); + printer.indent(() => { for (const { op, ident } of operationIdents(model)) { const descriptor = { id: op.specName ?? op.name, @@ -655,17 +655,17 @@ export const pythonGenerator: Generator = ({ model, outputPath, emit }) => { ? { pagination: paginationSpecs.get(ident) } : {}), }; - writer.line(`"${ident}": ${pythonLiteral(descriptor)},`); + printer.line(`"${ident}": ${pythonLiteral(descriptor)},`); } }); - writer.line('}'); - writer.blank(); - writer.blank(); + printer.line('}'); + printer.blank(); + printer.blank(); - writeClientClass(writer, model, errorMode, false, paginationSpecs); - writeClientClass(writer, model, errorMode, true, paginationSpecs); + writeClientClass(printer, model, errorMode, false, paginationSpecs); + writeClientClass(printer, model, errorMode, true, paginationSpecs); - return [{ path: outputPath.replace(/\.[^.\\/]+$/, '.py'), content: writer.toString() }]; + return [{ path: outputPath.replace(/\.[^.\\/]+$/, '.py'), content: printer.toString() }]; }; /** One idiomatic Python call per operation — feeds `x-codeSamples` for docs. */ From 637218d6dd5436a6698657798fbb0b650128f11a Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 4 Aug 2026 15:57:16 +0300 Subject: [PATCH 072/211] chore: remove the unreleased architect-generator command --- .changeset/agent-friendly-generators.md | 2 +- docs/@v2/commands/architect-generator.md | 34 ------ docs/@v2/commands/index.md | 1 - .../@v2/guides/customize-client-generation.md | 12 +- docs/@v2/usage-data.md | 3 +- docs/@v2/v2.sidebars.yaml | 2 - .../commands/eject-generator.test.ts | 13 +-- .../cli/src/commands/architect-generator.ts | 107 ------------------ packages/cli/src/index.ts | 25 ---- packages/cli/src/types.ts | 4 +- .../src/utils/generate-client-telemetry.ts | 8 +- packages/cli/src/utils/telemetry.ts | 4 +- tests/e2e/generate-client/eject.test.ts | 22 +--- tests/e2e/generate-client/examples.test.ts | 18 ++- tests/e2e/generate-client/examples/README.md | 1 - .../examples/architected-generator/.gitignore | 4 - .../examples/architected-generator/README.md | 12 -- .../generators/AGENTS.md | 80 ------------- .../generators/ops-summary.mjs | 23 ---- .../architected-generator/package.json | 14 --- .../architected-generator/redocly.yaml | 9 -- 21 files changed, 24 insertions(+), 374 deletions(-) delete mode 100644 docs/@v2/commands/architect-generator.md delete mode 100644 packages/cli/src/commands/architect-generator.ts delete mode 100644 tests/e2e/generate-client/examples/architected-generator/.gitignore delete mode 100644 tests/e2e/generate-client/examples/architected-generator/README.md delete mode 100644 tests/e2e/generate-client/examples/architected-generator/generators/AGENTS.md delete mode 100644 tests/e2e/generate-client/examples/architected-generator/generators/ops-summary.mjs delete mode 100644 tests/e2e/generate-client/examples/architected-generator/package.json delete mode 100644 tests/e2e/generate-client/examples/architected-generator/redocly.yaml diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index b9f53acd46..e32f7cda15 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -3,6 +3,6 @@ '@redocly/cli': minor --- -Added agent-friendly client generation: built-in `python`, `go`, `php`, and `cli` generators, a language-neutral authoring toolkit with a per-generator `AGENTS.md` skill, `eject-generator` and `architect-generator` commands, `x-codeSamples` output, and verification against large real-world descriptions — with every generator now emitting through source-text templates. +Added agent-friendly client generation: built-in `python`, `go`, `php`, and `cli` generators, a language-neutral authoring toolkit with a per-generator `AGENTS.md` skill, an `eject-generator` command, `x-codeSamples` output, and verification against large real-world descriptions — with every generator now emitting through source-text templates. **Note:** the AST exports (`ts`, `printStatements`, `schemaToTypeNode`, …) were removed from `@redocly/client-generator/generate` in favor of the text toolkit (`tsType`, `tsJsdoc`, `codeLiteral`). diff --git a/docs/@v2/commands/architect-generator.md b/docs/@v2/commands/architect-generator.md deleted file mode 100644 index 93ac48fad6..0000000000 --- a/docs/@v2/commands/architect-generator.md +++ /dev/null @@ -1,34 +0,0 @@ -# `architect-generator` - -## Introduction - -The `architect-generator` command creates a custom client-generator skeleton — for emitting an artifact no built-in generator covers (a route map, a facade, an SDK in another language). -It also drops `AGENTS.md`, the authoring guide that teaches your coding agent the generator contract, the API model shape, and the language-neutral helpers. - -## Usage - -```bash -redocly architect-generator route-map -redocly architect-generator my-sdk --dir ./generators -``` - -## Options - -| Option | Type | Description | -| --------- | ------ | -------------------------------------------------------------------- | -| generator | string | Name for the new generator (kebab-case; built-in names are refused). | -| `--dir` | string | Directory to architect into. Default `./generators`. | - -## How it works - -The skeleton is a runnable generator: it walks every operation of the API description and emits one file. -Replace its body with your output logic — the `Printer`, naming, and schema helpers from `@redocly/client-generator` (installed once as a dev dependency) handle indentation, identifier sanitization, and schema semantics in any output language. - -```yaml -client: - generators: - - sdk - - ./generators/route-map.mjs -``` - -To vendor and customize a built-in language generator instead, use [`eject-generator`](./eject-generator.md). diff --git a/docs/@v2/commands/index.md b/docs/@v2/commands/index.md index 5719210ea6..93c3d48407 100644 --- a/docs/@v2/commands/index.md +++ b/docs/@v2/commands/index.md @@ -16,7 +16,6 @@ API management commands: - [`bundle`](bundle.md) Bundle API description. - [`generate-client`](generate-client.md) Generate a typed TypeScript client from an OpenAPI description [experimental feature]. - [`eject-generator`](eject-generator.md) Vendor a built-in client generator into your repo as an editable file [experimental feature]. -- [`architect-generator`](architect-generator.md) Create a custom client-generator skeleton plus the authoring guide [experimental feature]. - [`join`](join.md) Join API descriptions [experimental feature]. - [`score`](score.md) Score an API for integration simplicity and AI agent readiness. - [`split`](split.md) Split API description into a multi-file structure. diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index 696f12d912..c2efcbd129 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -61,15 +61,13 @@ Express un-bypassable behavior as middleware, not a custom `fetch`. A setup file may import **only** from `@redocly/client-generator`. See the [`baked-setup` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/baked-setup). -## Eject and architect +## Eject -The fastest paths to a customized generator are the two commands: +The fastest path to a customized generator is +[`redocly eject-generator `](../commands/eject-generator.md): it vendors a built-in language generator (`python`, `go`, `php`) into `./generators/` as an editable file, with a pristine snapshot for [three-way updates](../commands/eject-generator.md#how-it-works) and the `AGENTS.md` authoring guide for your coding agent. +An ejected-unmodified generator produces byte-identical output, and the path entry takes over the built-in name — regeneration survives every customization. -- [`redocly eject-generator `](../commands/eject-generator.md) vendors a built-in language generator (`python`, `go`, `php`) into `./generators/` as an editable file, with a pristine snapshot for [three-way updates](../commands/eject-generator.md#how-it-works) and the `AGENTS.md` authoring guide for your coding agent. - An ejected-unmodified generator produces byte-identical output, and the path entry takes over the built-in name — regeneration survives every customization. -- [`redocly architect-generator `](../commands/architect-generator.md) creates a runnable skeleton for an artifact no built-in covers. - -Both drop `AGENTS.md` next to the generator: your agent reads it to learn the model shape, the helper library, and the verify loop (edit the generator → `redocly generate-client` → review the client diff — generated files are never hand-edited). +Eject drops `AGENTS.md` next to the generator: your agent reads it to learn the model shape, the helper library, and the verify loop (edit the generator → `redocly generate-client` → review the client diff — generated files are never hand-edited). ## Custom generators diff --git a/docs/@v2/usage-data.md b/docs/@v2/usage-data.md index 69e0a08d46..5830494b08 100644 --- a/docs/@v2/usage-data.md +++ b/docs/@v2/usage-data.md @@ -23,8 +23,7 @@ When a command is run, the following data is collected: - Arazzo x-security authentication types - for `generate-client`: which built-in generators ran, the count of custom generators, which of the package's own exported helper names a custom generator imports, and a coarse error category on failure. When a path-loaded generator carries the `eject-generator` provenance header, its built-in origin and the version it was ejected from are included (for example `php@0.2.0`) — the file's contents, path, and any user-chosen names are never transmitted. -- for `eject-generator` and `architect-generator`: the action (`eject`, `update`, `guidance`, `architect`), the built-in generator name for eject actions, and a coarse outcome category (such as `success`, `conflicts` with the conflict count, `already-exists`, or `merge-tool-missing`). - A architected generator's name is user-chosen and is never transmitted. +- for `eject-generator`: the action (`eject`, `update`, `guidance`), the built-in generator name, and a coarse outcome category (such as `success`, `conflicts` with the conflict count, `already-exists`, or `merge-tool-missing`). Custom generator file contents, paths, and names are never collected. - platform (Linux, macOS, Windows) - anonymous ID (a randomly generated identifier that doesn't contain personal information) diff --git a/docs/@v2/v2.sidebars.yaml b/docs/@v2/v2.sidebars.yaml index 398ec757b3..c9f7748058 100644 --- a/docs/@v2/v2.sidebars.yaml +++ b/docs/@v2/v2.sidebars.yaml @@ -44,8 +44,6 @@ page: commands/push-status.md - label: respect page: commands/respect.md - - label: architect-generator - page: commands/architect-generator.md - label: score page: commands/score.md - label: scorecard-classic diff --git a/packages/cli/src/__tests__/commands/eject-generator.test.ts b/packages/cli/src/__tests__/commands/eject-generator.test.ts index 2e89cea6fd..8c7cb529e2 100644 --- a/packages/cli/src/__tests__/commands/eject-generator.test.ts +++ b/packages/cli/src/__tests__/commands/eject-generator.test.ts @@ -1,4 +1,3 @@ -import { handleArchitectGenerator } from '../../commands/architect-generator.js'; import { handleEjectGenerator } from '../../commands/eject-generator.js'; import { ejectGeneratorTelemetry } from '../../utils/generate-client-telemetry.js'; import type { CommandArgs } from '../../wrapper.js'; @@ -14,7 +13,7 @@ function reset() { } } -describe('eject/architect telemetry (coarse categories only)', () => { +describe('eject telemetry (coarse categories only)', () => { beforeEach(reset); it('sdk guidance records the allowlisted name and a guidance action', async () => { @@ -36,14 +35,4 @@ describe('eject/architect telemetry (coarse categories only)', () => { expect(ejectGeneratorTelemetry.eject_generator_outcome).toBe('unknown-generator'); expect(ejectGeneratorTelemetry.eject_generator_name).toBeUndefined(); }); - - it('architecting a built-in name records the refusal, not the name', async () => { - await expect( - handleArchitectGenerator({ ...baseArgs, argv: { generator: 'php' } } as CommandArgs) - ).rejects.toThrow(/built-in generator/); - expect(ejectGeneratorTelemetry).toEqual({ - eject_generator_action: 'architect', - eject_generator_outcome: 'builtin-name', - }); - }); }); diff --git a/packages/cli/src/commands/architect-generator.ts b/packages/cli/src/commands/architect-generator.ts deleted file mode 100644 index 8abed67dd6..0000000000 --- a/packages/cli/src/commands/architect-generator.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { HandledError, logger } from '@redocly/openapi-core'; -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { join, relative, resolve } from 'node:path'; - -import { ejectGeneratorTelemetry } from '../utils/generate-client-telemetry.js'; -import { type CommandArgs } from '../wrapper.js'; -import { ejectAssetsDir } from './eject-generator.js'; - -export type ArchitectGeneratorCommandArgv = { - generator?: string; - config?: string; - dir?: string; -}; - -const BUILTIN_NAMES = new Set([ - 'sdk', - 'zod', - 'tanstack-query', - 'tanstack-query-vue', - 'tanstack-query-svelte', - 'tanstack-query-solid', - 'swr', - 'transformers', - 'mock', - 'cli', - 'python', - 'go', - 'php', -]); - -function skeleton(name: string): string { - return `// A custom generator architected by \`redocly architect-generator\`. -// It runs from the \`generators\` list in redocly.yaml and emits files next to the -// configured client output. The authoring guide for your agent is in ./AGENTS.md; -// the deep reference is the "Customize client generation" guide in the Redocly docs. -import { Printer, identifierFor } from '@redocly/client-generator'; - -export default { - name: '${name}', - - /** - * @param {{ model: import('@redocly/client-generator').ApiModel, outputPath: string }} input - * @returns {{ path: string, content: string }[]} - */ - run({ model, outputPath }) { - const writer = new Printer(' '); - writer.line(\`// \${model.title} \${model.version} — generated by the "${name}" generator.\`); - for (const service of model.services) { - for (const op of service.operations) { - // Every operation of the API description; \`op.name\` is a sanitized identifier, - // \`op.pathParams\`/\`op.queryParams\`/\`op.requestBody\` describe its inputs. - writer.line(\`// \${op.method.toUpperCase()} \${op.path} — \${identifierFor(op.name)}\`); - } - } - return [{ path: outputPath.replace(/\\.[^.]+$/, '.${name}.txt'), content: writer.toString() }]; - }, -}; -`; -} - -export const handleArchitectGenerator = async ({ - argv, -}: CommandArgs) => { - const name = argv.generator ?? ''; - // Coarse usage telemetry: action + outcome category only — a architected generator's - // name is user-chosen and never transmitted. - ejectGeneratorTelemetry.eject_generator_action = 'architect'; - if (!/^[a-z][a-z0-9-]*$/.test(name)) { - ejectGeneratorTelemetry.eject_generator_outcome = 'invalid-name'; - throw new HandledError( - `\n❌ Generator name must be kebab-case (got "${name}"). Example: redocly architect-generator route-map\n` - ); - } - if (BUILTIN_NAMES.has(name)) { - ejectGeneratorTelemetry.eject_generator_outcome = 'builtin-name'; - throw new HandledError( - `\n❌ "${name}" is a built-in generator — use \`redocly eject-generator ${name}\` to vendor it, or pick another name.\n` - ); - } - const dir = resolve(argv.dir ?? './generators'); - const target = join(dir, `${name}.mjs`); - if (existsSync(target)) { - ejectGeneratorTelemetry.eject_generator_outcome = 'already-exists'; - throw new HandledError(`\n❌ ${relative(process.cwd(), target)} already exists.\n`); - } - mkdirSync(dir, { recursive: true }); - writeFileSync(target, skeleton(name), 'utf-8'); - - // The same AGENTS.md drop the eject command performs (markers keep user additions safe). - const template = readFileSync(join(ejectAssetsDir(), 'AGENTS.md'), 'utf-8').trim(); - const agents = join(dir, 'AGENTS.md'); - if (!existsSync(agents)) { - writeFileSync( - agents, - `\n\n${template}\n\n\n`, - 'utf-8' - ); - } - - ejectGeneratorTelemetry.eject_generator_outcome = 'success'; - const configPath = `./${relative(process.cwd(), target).split('\\').join('/')}`; - logger.info( - `Architected ${relative(process.cwd(), target)}.\n` + - `Add it to your config and run \`redocly generate-client\`:\n\n` + - ` client:\n generators:\n - sdk\n - ${configPath}\n` - ); -}; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 79c55b1447..c791b6ba4a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -12,10 +12,6 @@ import * as path from 'node:path'; import yargs, { type Arguments } from 'yargs'; import { hideBin } from 'yargs/helpers'; -import { - handleArchitectGenerator, - type ArchitectGeneratorCommandArgv, -} from './commands/architect-generator.js'; import { handleLogin, handleLogout } from './commands/auth.js'; import type { BuildDocsArgv } from './commands/build-docs/types.js'; import { handleBundle } from './commands/bundle.js'; @@ -995,27 +991,6 @@ yargs(hideBin(process.argv)) commandWrapper(handleEjectGenerator)(argv as Arguments); } ) - .command( - 'architect-generator [generator]', - 'Create a custom client-generator skeleton plus the authoring guide (AGENTS.md) [experimental].', - (yargs) => - yargs - .positional('generator', { - describe: 'Name for the new generator (kebab-case).', - type: 'string', - }) - .options({ - dir: { - describe: 'Directory to architect into.', - type: 'string', - default: './generators', - requiresArg: true, - }, - }), - async (argv) => { - commandWrapper(handleArchitectGenerator)(argv as Arguments); - } - ) .command( 'generate-spec ', 'Infer an OpenAPI description from recorded HTTP traffic, optionally refined with AI [experimental].', diff --git a/packages/cli/src/types.ts b/packages/cli/src/types.ts index 88aa725b7f..ce487055af 100644 --- a/packages/cli/src/types.ts +++ b/packages/cli/src/types.ts @@ -1,6 +1,5 @@ import type { RuleSeverity } from '@redocly/openapi-core'; -import type { ArchitectGeneratorCommandArgv } from './commands/architect-generator.js'; import type { LoginArgv, LogoutArgv } from './commands/auth.js'; import type { BuildDocsArgv } from './commands/build-docs/types.js'; import type { BundleArgv } from './commands/bundle.js'; @@ -49,8 +48,7 @@ export type CommandArgv = | DriftArgv | ProxyArgv | GenerateArazzoCommandArgv - | EjectGeneratorCommandArgv - | ArchitectGeneratorCommandArgv; + | EjectGeneratorCommandArgv; export type VerifyConfigOptions = { config?: string; diff --git a/packages/cli/src/utils/generate-client-telemetry.ts b/packages/cli/src/utils/generate-client-telemetry.ts index c4b5c3da03..9bf861de90 100644 --- a/packages/cli/src/utils/generate-client-telemetry.ts +++ b/packages/cli/src/utils/generate-client-telemetry.ts @@ -79,14 +79,14 @@ export function categorizeGenerateClientError(message: string): string { } export type EjectGeneratorTelemetry = { - /** 'eject' | 'update' | 'guidance' | 'architect'. */ + /** 'eject' | 'update' | 'guidance'. */ eject_generator_action?: string; - /** Allowlisted built-in name only; architect and unknown names stay unnamed. */ + /** Allowlisted built-in name only; unknown names stay unnamed. */ eject_generator_name?: string; - /** Coarse outcome: success | conflicts | already-exists | missing-pristine | merge-tool-missing | unknown-generator | builtin-name | invalid-name. */ + /** Coarse outcome: success | conflicts | already-exists | missing-pristine | merge-tool-missing | unknown-generator. */ eject_generator_outcome?: string; eject_generator_conflicts?: number; }; -/** Populated by the eject/architect handlers; spread into the telemetry payload by the wrapper. */ +/** Populated by the eject-generator handler; spread into the telemetry payload by the wrapper. */ export const ejectGeneratorTelemetry: EjectGeneratorTelemetry = {}; diff --git a/packages/cli/src/utils/telemetry.ts b/packages/cli/src/utils/telemetry.ts index 42a0ea65fb..d0d68b78ec 100644 --- a/packages/cli/src/utils/telemetry.ts +++ b/packages/cli/src/utils/telemetry.ts @@ -152,8 +152,8 @@ export async function sendTelemetry({ ?.length ? JSON.stringify(generate_client.generate_client_ejected_generators) : undefined, - // eject-generator / architect-generator usage (action, allowlisted name, coarse - // outcome — never user paths or user-chosen names). + // eject-generator usage (action, allowlisted name, coarse outcome — never + // user paths or user-chosen names). eject_generator_action: eject_generator?.eject_generator_action, eject_generator_name: eject_generator?.eject_generator_name, eject_generator_outcome: eject_generator?.eject_generator_outcome, diff --git a/tests/e2e/generate-client/eject.test.ts b/tests/e2e/generate-client/eject.test.ts index bf2ee504d9..9b43669aab 100644 --- a/tests/e2e/generate-client/eject.test.ts +++ b/tests/e2e/generate-client/eject.test.ts @@ -34,7 +34,7 @@ function run(cwd: string, args: string[]) { return spawnSync('node', [cliEntry, ...args], { cwd, encoding: 'utf-8' }); } -describe('eject-generator / architect-generator (end-to-end)', () => { +describe('eject-generator (end-to-end)', () => { let project: string; beforeAll(() => { @@ -118,24 +118,4 @@ describe('eject-generator / architect-generator (end-to-end)', () => { expect(conflicted.stderr + conflicted.stdout).toContain('conflict'); expect(readFileSync(join(project, 'generators/php.mjs'), 'utf-8')).toContain('<<<<<<<'); }, 60_000); - - it('architect-generator creates a runnable skeleton; built-in names are refused', () => { - const architect = run(project, ['architect-generator', 'route-map']); - expect(architect.status, architect.stderr).toBe(0); - const generate = run(project, [ - 'generate-client', - 'openapi.yaml', - '--output', - 'architected/client.ts', - '--generator', - 'sdk', - '--generator', - './generators/route-map.mjs', - ]); - expect(generate.status, generate.stderr).toBe(0); - expect(readFileSync(join(project, 'architected/client.route-map.txt'), 'utf-8')).toContain( - 'GET /orders — listOrders' - ); - expect(run(project, ['architect-generator', 'php']).status).not.toBe(0); - }, 60_000); }); diff --git a/tests/e2e/generate-client/examples.test.ts b/tests/e2e/generate-client/examples.test.ts index 3217e32b12..c90c116e08 100644 --- a/tests/e2e/generate-client/examples.test.ts +++ b/tests/e2e/generate-client/examples.test.ts @@ -85,7 +85,7 @@ describe('examples generate with the current generator', () => { }); describe('generator-authoring examples carry the current AGENTS.md', () => { - // The eject/architect examples commit the AGENTS.md drop so browsers see the full + // The ejected example commits the AGENTS.md drop so browsers see the full // story; this pins them byte-for-byte to the shipped template (markers included). const template = readFileSync( join(repoRoot, 'packages/client-generator/eject-assets/AGENTS.md'), @@ -93,13 +93,11 @@ describe('generator-authoring examples carry the current AGENTS.md', () => { ).trim(); const expected = `\n\n${template}\n\n\n`; - for (const example of ['ejected-generator', 'architected-generator']) { - it(`${example}/generators/AGENTS.md matches the shipped template`, () => { - const dropped = readFileSync(join(examplesDir, example, 'generators/AGENTS.md'), 'utf-8'); - expect( - dropped, - `stale — re-run \`redocly eject-generator\` or \`architect-generator\` in the example` - ).toBe(expected); - }); - } + it('ejected-generator/generators/AGENTS.md matches the shipped template', () => { + const dropped = readFileSync( + join(examplesDir, 'ejected-generator', 'generators/AGENTS.md'), + 'utf-8' + ); + expect(dropped, 'stale — re-run `redocly eject-generator` in the example').toBe(expected); + }); }); diff --git a/tests/e2e/generate-client/examples/README.md b/tests/e2e/generate-client/examples/README.md index dbbf40516b..86093d66d6 100644 --- a/tests/e2e/generate-client/examples/README.md +++ b/tests/e2e/generate-client/examples/README.md @@ -31,7 +31,6 @@ The generated client under `src/api/` is gitignored — CI regenerates every cli | [go-sdk](./go-sdk) | CLI · `go` | a full Go SDK (stdlib-only): typed structs, `(T, error)` methods, `context.Context` | | [php-sdk](./php-sdk) | CLI · `php` | a full PHP SDK (curl extension): promoted-constructor classes, native enums, named-argument methods | | [ejected-generator](./ejected-generator) | CLI · ejected `php` | `eject-generator php` vendored + customized: the path entry shadows the built-in name; regeneration keeps the customization | -| [architected-generator](./architected-generator) | CLI · `sdk` + architected | `architect-generator` skeleton filled in — a markdown operations summary emitted next to the client | ## Run one diff --git a/tests/e2e/generate-client/examples/architected-generator/.gitignore b/tests/e2e/generate-client/examples/architected-generator/.gitignore deleted file mode 100644 index 9c0c76b464..0000000000 --- a/tests/e2e/generate-client/examples/architected-generator/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules -src/api/ -package-lock.json -generators/my-generator.mjs diff --git a/tests/e2e/generate-client/examples/architected-generator/README.md b/tests/e2e/generate-client/examples/architected-generator/README.md deleted file mode 100644 index f9bec6c049..0000000000 --- a/tests/e2e/generate-client/examples/architected-generator/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# architected-generator - -`redocly architect-generator ops-summary` created the skeleton for `generators/ops-summary.mjs` plus `generators/AGENTS.md` (committed here) — the authoring guide your coding agent uses as context to fill the skeleton in; this example evolved it into a markdown operations summary emitted next to the client. -The generator reads the same API model the built-ins consume, so the summary regenerates with the spec and can never drift from it. - -```sh -npm run generate -cat src/api/client.operations.md -npm run architect # try the command yourself: architects a fresh generators/my-generator.mjs -``` - -To customize a built-in language generator instead of writing one from scratch, see the [`ejected-generator`](../ejected-generator) example. diff --git a/tests/e2e/generate-client/examples/architected-generator/generators/AGENTS.md b/tests/e2e/generate-client/examples/architected-generator/generators/AGENTS.md deleted file mode 100644 index bea3e74dd1..0000000000 --- a/tests/e2e/generate-client/examples/architected-generator/generators/AGENTS.md +++ /dev/null @@ -1,80 +0,0 @@ - - -# Writing custom client generators - -A generator is a plain module: `(input) => GeneratedFile[]`. It receives the -language-agnostic API model and returns files — in ANY output language. It runs -in the same pass as the built-ins; select it by path in `redocly.yaml`: - -```yaml -client: - generators: [sdk, ./generators/my-generator.mjs] -``` - -## The contract - -```js -/** @type {import('@redocly/client-generator').CustomGenerator} */ -export default { - name: 'my-generator', - run({ model, outputPath, outputMode, emit }) { - return [{ path: outputPath.replace(/\.ts$/, '.mine.txt'), content: '…' }]; - }, - // Optional: one idiomatic call snippet per operation for docs (x-codeSamples), - // collected into an overlay file when `client.codeSamples: true` is set. - sample(operation, { model, emit }) { - return { lang: 'python', source: '…' }; - }, -}; -``` - -Rules: output is deterministic (same description → same bytes); never add -dependencies to the generated client; **never hand-edit generated output** — -edit this generator and regenerate. - -## The model (IR) - -`model.services[].operations[]` — each operation carries `name`, `specName`, -`method`, `path`, `tags`, `pathParams`/`queryParams`/`headerParams`/`cookieParams`, -`requestBody`, `successResponses`/`errorResponses` (each with a `schema`), and -`security`. `model.schemas` holds the named schemas. Every schema is a -discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, -`literal`, `enum`, `union` (optionally with a discriminator), `intersection` -(allOf), `null`, `unknown`, `omit`. - -## Helpers (import from '@redocly/client-generator') - -| Helper | Use | -| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| `flattenAllOf(schema, model)` | The merged property view of allOf compositions — languages without intersection types render this. | -| `discriminatorCases(schema, model)` | `{ property, cases }` dispatch table for discriminated unions. | -| `isNullable(schema)` / `unwrapNullable(schema)` | Detect and strip `null` union members (`Optional[T]`, pointers, `Option`). | -| `enumValues(schema)` | Values plus SCREAMING_SNAKE member-name suggestions. | -| `casing` / `identifierFor(name, { style, reserved })` | camel/pascal/snake/screaming; keyword-safe identifiers (`RESERVED_WORDS.python/go/typescript` shipped). | -| `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. | -| `AUTHORING_HELPER_NAMES` | The list of the above (introspection). | - -Worked example: the built-in `python` generator -(`packages/client-generator/src/generators/python/index.ts` in the Redocly CLI repo) is -authored with exactly this toolkit and nothing else — models via `flattenAllOf`/ -`enumValues`/`discriminatorCases`, all code through `Printer`, every name through -`identifierFor(..., RESERVED_WORDS.python)`. - -TypeScript-emitting generators may additionally use the TS toolkit from -`@redocly/client-generator/generate` (`ts`, `printStatements`, `schemaToTypeNode`, …). - -## The loop - -1. Edit the generator. -2. Run `redocly generate-client`. -3. Inspect `git diff` of the generated output. -4. Repeat. Generated files are never hand-edited. - -If you had to work around a **missing helper** or a wrong default, tell the user -and suggest filing an issue at https://github.com/Redocly/redocly-cli/issues — -include the generator snippet and the helper you expected to exist. - - diff --git a/tests/e2e/generate-client/examples/architected-generator/generators/ops-summary.mjs b/tests/e2e/generate-client/examples/architected-generator/generators/ops-summary.mjs deleted file mode 100644 index d90813fae8..0000000000 --- a/tests/e2e/generate-client/examples/architected-generator/generators/ops-summary.mjs +++ /dev/null @@ -1,23 +0,0 @@ -// Architected with `redocly architect-generator ops-summary`, then filled in: -// emits a markdown operations summary next to the client — an artifact no -// built-in generator covers, derived from the same API model, so it can -// never drift from the description. -import { Printer } from '@redocly/client-generator'; - -export default { - name: 'ops-summary', - run({ model, outputPath }) { - const writer = new Printer(' '); - writer.line(`# ${model.title} ${model.version} — operations`); - writer.blank(); - writer.line('| Operation | Method | Path | Summary |'); - writer.line('| --- | --- | --- | --- |'); - for (const service of model.services) { - for (const op of service.operations) { - const summary = (op.summary ?? '').split('\n')[0]; - writer.line(`| ${op.name} | ${op.method.toUpperCase()} | \`${op.path}\` | ${summary} |`); - } - } - return [{ path: outputPath.replace(/\.[^.]+$/, '.operations.md'), content: writer.toString() }]; - }, -}; diff --git a/tests/e2e/generate-client/examples/architected-generator/package.json b/tests/e2e/generate-client/examples/architected-generator/package.json deleted file mode 100644 index 5f1ded4014..0000000000 --- a/tests/e2e/generate-client/examples/architected-generator/package.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "@redocly-examples/architected-generator", - "private": true, - "version": "0.0.0", - "type": "module", - "scripts": { - "architect": "redocly architect-generator my-generator", - "generate": "redocly generate-client" - }, - "devDependencies": { - "@redocly/cli": "latest", - "@redocly/client-generator": "latest" - } -} diff --git a/tests/e2e/generate-client/examples/architected-generator/redocly.yaml b/tests/e2e/generate-client/examples/architected-generator/redocly.yaml deleted file mode 100644 index 6276bcff71..0000000000 --- a/tests/e2e/generate-client/examples/architected-generator/redocly.yaml +++ /dev/null @@ -1,9 +0,0 @@ -# redocly.yaml — drives `redocly generate-client` for this example. -apis: - architected-generator: - root: ../_shared/cafe.yaml - clientOutput: ./src/api/client.ts - client: - generators: - - sdk - - ./generators/ops-summary.mjs From 3329229e643d39e80e26836d15d0fd65637dc72d Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Thu, 6 Aug 2026 15:13:12 +0300 Subject: [PATCH 073/211] feat(client-generator): port the response-headers envelope to the text emitters Carries PR #3002's throw-mode `{ envelope: true }` support over to the text template emitters, replacing the AST helpers the toolkit removal dropped. --- .../src/emitters/__tests__/descriptor.test.ts | 74 ++++++++++++++++++- .../src/emitters/client-assembly.ts | 4 + .../src/emitters/descriptor.ts | 11 ++- .../src/emitters/operations.ts | 3 + .../src/emitters/render-client.ts | 44 ++++++++++- .../src/emitters/response-headers.ts | 33 +++------ .../src/emitters/runtime-sources.ts | 13 +++- packages/client-generator/src/emitters/swr.ts | 5 +- .../src/emitters/wrapper-support.ts | 9 ++- 9 files changed, 160 insertions(+), 36 deletions(-) diff --git a/packages/client-generator/src/emitters/__tests__/descriptor.test.ts b/packages/client-generator/src/emitters/__tests__/descriptor.test.ts index f833a6e8c6..614768843e 100644 --- a/packages/client-generator/src/emitters/__tests__/descriptor.test.ts +++ b/packages/client-generator/src/emitters/__tests__/descriptor.test.ts @@ -342,6 +342,78 @@ describe('renderDescriptors', () => { // Non-paginated entries carry no pagination field. expect(out).toContain('ping: { id: "ping", method: "GET", path: "/ping" }'); }); + + it('emits responseHeaders coerce specs from declared success-response headers', () => { + const out = emitDescriptors( + modelWith([ + operation({ + name: 'listCustomers', + path: '/customers', + successResponses: [JSON_OK], + successResponseHeaders: [ + { + name: 'pagination-total', + schema: { kind: 'scalar', scalar: 'integer' }, + required: true, + }, + { name: 'link', schema: { kind: 'scalar', scalar: 'string' } }, + ], + }), + ]) + ); + expect(out).toContain( + 'responseHeaders: [{ name: "pagination-total", key: "paginationTotal", type: "number" }, { name: "link", key: "link", type: "string" }]' + ); + }); + + it('emits safe unique response-header descriptor keys', () => { + const out = emitDescriptors( + modelWith([ + operation({ + name: 'listCustomers', + successResponses: [JSON_OK], + successResponseHeaders: [ + { name: '3d-secure', schema: { kind: 'scalar', scalar: 'boolean' } }, + { name: 'x-foo', schema: { kind: 'scalar', scalar: 'integer' } }, + { name: 'x_foo', schema: { kind: 'scalar', scalar: 'string' } }, + ], + }), + ]) + ); + expect(out).toContain( + 'responseHeaders: [{ name: "3d-secure", key: "_3dSecure", type: "boolean" }, { name: "x-foo", key: "xFoo", type: "number" }, { name: "x_foo", key: "xFoo_2", type: "string" }]' + ); + }); + + it('unwraps nullable header schemas to the inner coerce type', () => { + const out = emitDescriptors( + modelWith([ + operation({ + name: 'listCustomers', + successResponses: [JSON_OK], + successResponseHeaders: [ + { + name: 'x-flag', + schema: { + kind: 'union', + members: [{ kind: 'scalar', scalar: 'boolean' }, { kind: 'null' }], + }, + }, + { + name: 'x-count', + schema: { + kind: 'union', + members: [{ kind: 'scalar', scalar: 'integer' }, { kind: 'null' }], + }, + }, + ], + }), + ]) + ); + expect(out).toContain( + 'responseHeaders: [{ name: "x-flag", key: "xFlag", type: "boolean" }, { name: "x-count", key: "xCount", type: "number" }]' + ); + }); }); describe('renderOpsType', () => { @@ -567,7 +639,7 @@ describe('renderOpsType', () => { // Result mode: `result` is the envelope, so `page` carries the raw page for `.pages()`. const out = emitOps(modelWith([listOrders]), { pagination, errorMode: 'result' }); expect(out).toMatch( - /listOrders: \{\n {8}args: \{\n {12}params\?: ListOrdersParams;\n {8}\};\n {8}result: Result;\n {8}item: Order;\n {8}page: ListOrdersResult;\n {4}\};/ + /listOrders: \{\n {8}args: \{\n {12}params\?: ListOrdersParams;\n {8}\};\n {8}result: Result;\n {8}mode: "result";\n {8}item: Order;\n {8}page: ListOrdersResult;\n {4}\};/ ); // Throw mode emits no page member — `result` already IS the raw page. expect(emitOps(modelWith([listOrders]), { pagination })).not.toContain('page:'); diff --git a/packages/client-generator/src/emitters/client-assembly.ts b/packages/client-generator/src/emitters/client-assembly.ts index 4582364a30..5fdf004adb 100644 --- a/packages/client-generator/src/emitters/client-assembly.ts +++ b/packages/client-generator/src/emitters/client-assembly.ts @@ -72,6 +72,7 @@ function emitClient( errorMode: options.errorMode ?? 'throw', dateType: options.dateType ?? 'string', schemaNames: new Set(model.schemas.map((s) => s.name)), + schemas: model.schemas, pagination, }; const flat = ctx.argsStyle === 'flat'; @@ -185,6 +186,8 @@ function importLine( 'OperationDescriptor', // Flat sugar signatures reference the per-call option types. ...(refs.hasFlatRegular ? ['RequestOptions'] : []), + // Flat throw-mode sugar return types vary with the inferred request-option type. + ...(refs.hasFlatRegular && ctx.errorMode !== 'result' ? ['EnvelopeResult'] : []), // `Ops` wraps results in `Result` in result mode — but only NON-SSE members // (an SSE-only spec would otherwise import it unused and fail noUnusedLocals). ...(ctx.errorMode === 'result' && refs.hasRegular ? ['Result'] : []), @@ -273,6 +276,7 @@ function sugarSection( function reexportLines(ctx: EmitContext, hasSse: boolean): string { const types = [ 'ClientConfig', + 'Envelope', 'Middleware', 'RequestOptions', ...(ctx.errorMode === 'result' ? ['Result'] : []), diff --git a/packages/client-generator/src/emitters/descriptor.ts b/packages/client-generator/src/emitters/descriptor.ts index b248ea298b..cb75e3bbe3 100644 --- a/packages/client-generator/src/emitters/descriptor.ts +++ b/packages/client-generator/src/emitters/descriptor.ts @@ -6,6 +6,7 @@ import { allOperations, type ApiModel, + type NamedSchemaModel, type OperationModel, type SecuritySchemeModel, } from '../intermediate-representation/model.js'; @@ -16,6 +17,7 @@ import { isTypedMultipart } from './operation-types.js'; import type { ModelPagination } from './pagination.js'; import { 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'; @@ -39,7 +41,8 @@ function descriptorValue( op: OperationModel, schemes: SecuritySchemeModel[], dateType: DateType, - pagination?: ModelPagination + pagination?: ModelPagination, + schemas: readonly NamedSchemaModel[] = [] ) { const params = [...op.pathParams, ...op.queryParams, ...op.headerParams, ...op.cookieParams].map( (p) => ({ @@ -67,6 +70,7 @@ function descriptorValue( .filter((alternative) => alternative.length > 0); const sse = isSseOp(op); const responseKind = sse ? 'sse' : responseText(op.successResponses, dateType).kind; + const responseHeaders = responseHeaderSpecs(op.successResponseHeaders, schemas); return { // The spec's operationId, NOT the (possibly renamed) map key: `id` drives middleware // targeting (`ctx.operation.id`) and must match inline mode's `operationMetaExpr`. @@ -88,6 +92,7 @@ function descriptorValue( ...(responseKind !== 'json' ? { responseKind } : {}), ...(sse ? { sseDataKind: sseDataKind(op) } : {}), ...(security.length > 0 ? { security } : {}), + ...(responseHeaders === undefined ? {} : { responseHeaders }), // The resolved spec is already normalized with stable key order (see pagination.ts). ...(pagination?.has(op.name) ? { pagination: pagination.get(op.name)!.spec } : {}), }; @@ -103,7 +108,9 @@ export function renderDescriptors( const ops = allOperations(model.services); if (ops.length === 0) return ''; const entryLines = ops.map((op, index) => { - const value = codeLiteral(descriptorValue(op, model.securitySchemes, dateType, pagination)); + const value = codeLiteral( + descriptorValue(op, model.securitySchemes, dateType, pagination, model.schemas) + ); return ` ${idents.get(op.name)!}: ${value}${index === ops.length - 1 ? '' : ','}`; }); const blocks = [ diff --git a/packages/client-generator/src/emitters/operations.ts b/packages/client-generator/src/emitters/operations.ts index 1708ba4552..1e5c169a56 100644 --- a/packages/client-generator/src/emitters/operations.ts +++ b/packages/client-generator/src/emitters/operations.ts @@ -1,3 +1,4 @@ +import type { NamedSchemaModel } from '../intermediate-representation/model.js'; import type { ModelPagination } from './pagination.js'; import type { DateType } from './types.js'; @@ -27,6 +28,8 @@ export type EmitContext = { 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 b539cc2a26..a73f21dbc5 100644 --- a/packages/client-generator/src/emitters/render-client.ts +++ b/packages/client-generator/src/emitters/render-client.ts @@ -15,6 +15,7 @@ import { isIdentifier, safeIdent } from './identifier.js'; import { operationSignature } 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'; @@ -234,6 +235,15 @@ export function renderOpsType( ? `Result<${rawResultText(op, ctx, inner)}, ${errorArgText(op, ctx, inner)}>` : rawResultText(op, ctx, inner); const lines = [`${inner}args: ${args};`, `${inner}result: ${result};`]; + // Result-mode entries mark themselves so the runtime's mapped methods skip the + // throw-only envelope typing; declared headers type the `{ envelope: true }` bag. + if (ctx.errorMode === 'result' && !sse) lines.push(`${inner}mode: "result";`); + const responseHeaders = op.successResponseHeaders; + if (responseHeaders && responseHeaders.length > 0) { + lines.push( + `${inner}headers: ${responseHeadersTypeText(responseHeaders, ctx.schemas, inner)};` + ); + } const paginated = ctx.pagination?.get(op.name); if (paginated) { lines.push(`${inner}item: ${tsType(paginated.itemSchema, ctx.dateType, inner)};`); @@ -293,6 +303,13 @@ export function renderAliases( if (op.headerParams.length > 0 && !schemaNames.has(`${name}Headers`)) { blocks.push(`export type ${name}Headers = ${paramsTypeText(op.headerParams, dateType)};`); } + // Response headers (envelope) — distinct from request `Headers`. + const responseHeaders = op.successResponseHeaders; + if (responseHeaders && responseHeaders.length > 0 && !schemaNames.has(`${name}ResponseHeaders`)) { + blocks.push( + `export type ${name}ResponseHeaders = ${responseHeadersTypeText(responseHeaders, ctx.schemas)};` + ); + } if (op.cookieParams.length > 0 && !schemaNames.has(`${name}Cookies`)) { blocks.push(`export type ${name}Cookies = ${paramsTypeText(op.cookieParams, dateType)};`); } @@ -315,7 +332,8 @@ function argListText( op: OperationModel, orderedPathParams: ParamModel[], pathParamIdent: Map, - ctx: EmitContext + ctx: EmitContext, + initParam: string ): string { const { dateType } = ctx; const args: string[] = orderedPathParams.map( @@ -331,18 +349,31 @@ function argListText( } if (op.headerParams.length > 0) args.push(slot('headers', op.headerParams)); if (op.cookieParams.length > 0) args.push(slot('cookies', op.cookieParams)); - args.push(`init: ${isSseOp(op) ? 'SseOptions' : 'RequestOptions'} = {}`); + args.push(initParam); return args.join(', '); } +/** The envelope headers type argument: alias, inline literal on collision, or the empty bag. */ +function flatHeadersText(op: OperationModel, ctx: EmitContext): string { + const headers = op.successResponseHeaders; + if (!headers || headers.length === 0) return 'Record'; + const alias = `${pascalCase(op.name)}ResponseHeaders`; + return ctx.schemaNames.has(alias) ? responseHeadersTypeText(headers, ctx.schemas) : alias; +} + /** One flat one-liner: the positional signature forwarding to the grouped client method. */ export function renderFlatSugar(op: OperationModel, ident: string, ctx: EmitContext): string { + const sse = isSseOp(op); + // Throw-mode (non-SSE) sugar is generic over `init` so `{ envelope: true }` narrows + // the return type to `Envelope<…>` (plain `RequestOptions` would collapse it). + const envelopeAware = !sse && ctx.errorMode !== 'result'; const { pathParams } = operationSignature(op); const params = argListText( op, pathParams.map((p) => p.param), new Map(pathParams.map((p) => [p.param.name, p.ident])), - ctx + ctx, + envelopeAware ? 'init?: I' : `init: ${sse ? 'SseOptions' : 'RequestOptions'} = {}` ); const props: string[] = pathParams.map(({ param, ident: paramIdent }) => param.name === paramIdent @@ -354,7 +385,12 @@ export function renderFlatSugar(op: OperationModel, ident: string, ctx: EmitCont if (op.headerParams.length > 0) props.push('headers'); if (op.cookieParams.length > 0) props.push('cookies'); const args = props.length === 0 ? '{}' : `{ ${props.join(', ')} }`; - const fn = `(${params}) => client.${ident}(${args}, init)`; + const fn = envelopeAware + ? (() => { + const promise = `Promise>`; + return `(${params}): ${promise} => client.${ident}(${args}, init) as ${promise}`; + })() + : `(${params}) => client.${ident}(${args}, init)`; if (!ctx.pagination?.has(op.name)) return `export const ${ident} = ${fn};`; return `export const ${ident} = Object.assign(${fn}, { pages: client.${ident}.pages, items: client.${ident}.items });`; } diff --git a/packages/client-generator/src/emitters/response-headers.ts b/packages/client-generator/src/emitters/response-headers.ts index bab047ea5e..828d23152b 100644 --- a/packages/client-generator/src/emitters/response-headers.ts +++ b/packages/client-generator/src/emitters/response-headers.ts @@ -1,4 +1,4 @@ -// Success-response header helpers: descriptor parse hints + Ops / alias type shapes +// Success-response header helpers: descriptor parse hints + Ops / alias type text // for throw-mode `{ envelope: true }`. import type { @@ -9,9 +9,8 @@ import type { import type { ResponseHeaderSpec } from '../runtime/types.js'; import { uniqueIdent } from './identifier.js'; import { headerPropertyKey } from './support.js'; -import { ts } from './ts.js'; -const { factory } = ts; +const INDENT = ' '; type PlannedResponseHeader = ResponseHeaderModel & { key: string; @@ -79,21 +78,17 @@ export function responseHeaderSpecs( })); } -/** Type literal for Ops.`headers` / `ResponseHeaders`. */ -export function responseHeadersTypeLiteral( +/** Type-literal text for Ops.`headers` / `ResponseHeaders`, rendered at `indent`. */ +export function responseHeadersTypeText( headers: ResponseHeaderModel[], - schemas: readonly NamedSchemaModel[] = [] -): ts.TypeNode { - return factory.createTypeLiteralNode( - planResponseHeaders(headers, schemas).map((header) => { - return factory.createPropertySignature( - undefined, - factory.createIdentifier(header.key), - header.required === true ? undefined : factory.createToken(ts.SyntaxKind.QuestionToken), - headerTypeNode(header.type) - ); - }) + schemas: readonly NamedSchemaModel[] = [], + indent = '' +): string { + const inner = indent + INDENT; + const lines = planResponseHeaders(headers, schemas).map( + (header) => `${inner}${header.key}${header.required === true ? '' : '?'}: ${header.type};` ); + return lines.length === 0 ? '{}' : `{\n${lines.join('\n')}\n${indent}}`; } function planResponseHeaders( @@ -107,9 +102,3 @@ function planResponseHeaders( type: headerParseType(header.schema, schemas), })); } - -function headerTypeNode(type: ResponseHeaderSpec['type']): ts.TypeNode { - if (type === 'number') return factory.createKeywordTypeNode(ts.SyntaxKind.NumberKeyword); - if (type === 'boolean') return factory.createKeywordTypeNode(ts.SyntaxKind.BooleanKeyword); - return factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword); -} diff --git a/packages/client-generator/src/emitters/runtime-sources.ts b/packages/client-generator/src/emitters/runtime-sources.ts index 1a915de404..1296c8e541 100644 --- a/packages/client-generator/src/emitters/runtime-sources.ts +++ b/packages/client-generator/src/emitters/runtime-sources.ts @@ -31,7 +31,7 @@ export const RUNTIME_SOURCES = { /** Inline-embed variants: imports dropped, `export` stripped outside the kept surface. */ export const RUNTIME_SOURCES_STRIPPED = { 'types.ts': - "/**\n * The public type surface of the client runtime — `@redocly/client-generator`'s\n * app-facing runtime module. Pure types, no runtime code (excluded from coverage).\n * The generator emits `OPERATIONS` literals typed\n * `satisfies Record` against this module, so an\n * incompatible runtime/generated pair fails the consumer's build (the semver skew guard).\n */\n\n/** How one operation parameter is sent: its location plus OpenAPI query-serialization hints. */\nexport type ParamSpec = {\n name: string;\n in: 'path' | 'query' | 'header' | 'cookie';\n style?: 'form' | 'spaceDelimited' | 'pipeDelimited' | 'deepObject';\n explode?: boolean;\n allowReserved?: boolean;\n};\n\n/** One security scheme, denormalized onto the operation (`scheme` names the spec's scheme). */\nexport type SecuritySpec =\n | { scheme: string; kind: 'bearer' | 'basic' }\n | { scheme: string; kind: 'apiKey'; name: string; in: 'header' | 'query' | 'cookie' };\n\n/**\n * How to auto-iterate a paginated operation (drives its `.pages()`/`.items()` members).\n * `nextCursor` and `items` are RFC 6901 JSON pointers into the page (response) value.\n */\nexport type PaginationSpec =\n | {\n style: 'cursor';\n /** The query param the iterator advances with the response's cursor. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the next cursor in the page. */\n nextCursor: string;\n /** Optional pointer to a boolean \"more pages\" flag — `false` stops iteration. */\n hasMore?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n style: 'offset' | 'page';\n /** The numeric query param the iterator advances. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n /** RFC 8288: follow the response's `Link` header `rel=\"next\"`; stop when absent. */\n style: 'link';\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n };\n\n/** The frozen data contract between generated code and the runtime: one operation's wire shape. */\nexport type OperationDescriptor = {\n id: string;\n method: string;\n path: string;\n tags?: readonly string[];\n params?: readonly ParamSpec[];\n /** `multipart: true` marks a typed object body serialized to FormData by the runtime. */\n body?: { contentType: string; multipart?: boolean };\n /** Defaults to `'json'` (content-type negotiation on parse). */\n responseKind?: 'json' | 'text' | 'blob' | 'void' | 'sse';\n sseDataKind?: 'json' | 'text';\n /** OR-alternatives, each an AND-set: the runtime applies the first fully-configured one. */\n security?: readonly (readonly SecuritySpec[])[];\n pagination?: PaginationSpec;\n};\n\n/** A query value: scalars, arrays of scalars, or objects (serialized as deepObject brackets). */\nexport type QueryValue =\n | string\n | number\n | boolean\n | null\n | undefined\n | Array\n | Record;\n\n/** A credential: a literal, or a (possibly async) function resolved per request (refresh flows). */\nexport type TokenProvider = string | (() => string | Promise);\n\n/** Per-instance credentials, keyed by the scheme kinds the runtime can inject. */\nexport type AuthCredentials = {\n bearer?: TokenProvider;\n basic?: { username: string; password: string };\n apiKey?: Record;\n};\n\n/** Backoff shape: 'fixed' = constant delay; 'exponential' = doubling per attempt. */\nexport type RetryStrategy = 'fixed' | 'exponential';\n\n/**\n * The operation's identity, exposed to middleware for targeting (`ctx.operation`).\n * Generated clients instantiate the type parameters with the spec's literal unions\n * (`OperationId`/`OperationPath`/`OperationTag`) so a misspelled operation id in a\n * middleware comparison fails to compile; the string defaults keep every\n * spec-independent consumer (`runtime-contract.ts`, the runtime internals) working\n * with the base shape. `tags` stays mutable (`Tag[]`) so setup-contract types\n * (byte-locked to generated output) remain assignable through middleware callbacks.\n */\nexport type OperationContext<\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n> = { id: Id; path: Path; tags: Tag[] };\n\n/** The mutable request context threaded through the middleware chain. */\nexport type RequestContext = {\n url: string;\n method: string;\n headers: Record;\n body?: unknown;\n operation: Op;\n};\n\n/** The failed attempt handed to a custom `retryOn`: exactly one of `response`/`error` is set. */\nexport type RetryContext = {\n attempt: number;\n request: RequestContext;\n response?: Response;\n error?: unknown;\n};\n\n/** Opt-in retry policy; a per-call override merges field-by-field over the config policy. */\nexport type RetryConfig = {\n retries?: number;\n retryDelay?: number;\n retryStrategy?: RetryStrategy;\n jitter?: boolean;\n retryOn?: (ctx: RetryContext) => boolean | Promise;\n};\n\n/**\n * Structural stand-in for the runtime's ApiError so this module stays import-free\n * (pure types); the real `ApiError` class is assignable to it.\n */\nexport type ApiErrorLike = globalThis.Error & {\n url: string;\n status: number;\n statusText: string;\n body: unknown;\n};\n\n/** One interceptor: any subset of the three hooks. */\nexport type Middleware = {\n onRequest?: (ctx: RequestContext) => void | Promise;\n onResponse?: (\n response: Response,\n ctx: RequestContext\n ) => Response | void | Promise;\n /** Throw mode only: may map/replace the error. */\n // `globalThis.Error` so a spec schema named `Error` cannot shadow it in inline mode.\n onError?: (\n error: ApiErrorLike,\n ctx: RequestContext\n ) => globalThis.Error | Promise;\n};\n\n/** Client configuration: transport, defaults, retry policy, middleware, and credentials. */\nexport type ClientConfig = {\n serverUrl?: string;\n fetch?: typeof fetch;\n headers?:\n | Record\n | (() => Record | Promise>);\n retry?: RetryConfig;\n /** Milliseconds before a request attempt aborts (covers the body read too; each retry\n * attempt gets a fresh budget). Per-call `timeout` overrides it, `0` disables it.\n * SSE streams are long-lived by design and never inherit this value. */\n timeout?: number;\n /** Send an `Idempotency-Key` header on POST/PATCH (one stable key per logical call,\n * reused across retry attempts) — which also makes those retries safe under the\n * default retry policy. `true` generates a UUID per call; a function supplies the key. */\n idempotencyKey?: boolean | (() => string);\n /** Identifies this client to the API via an `X-Redocly-Client` header (the generator\n * bakes a default). Sent only OUTSIDE browsers — a custom header would force a CORS\n * preflight. Override with your own value, or `false` to disable. */\n clientHeader?: string | false;\n middleware?: Middleware[];\n auth?: AuthCredentials;\n /** Fixed at generate time by the generator (`'throw'` when omitted); `configure()` ignores it. */\n errorMode?: 'throw' | 'result';\n onRequest?: Middleware['onRequest'];\n onResponse?: Middleware['onResponse'];\n onError?: Middleware['onError'];\n};\n\n/** Response readers for the per-call `parseAs` override. */\nexport type ParseAs = 'auto' | 'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData' | 'stream';\n\n/** Per-call options: standard `RequestInit` plus a retry override, a timeout override\n * (`0` disables the config default), and a forced reader. */\nexport type RequestOptions = RequestInit & {\n retry?: RetryConfig;\n timeout?: number;\n /** Per-call idempotency key: a literal key, `true` to generate one, `false` to skip. */\n idempotencyKey?: string | boolean | (() => string);\n parseAs?: ParseAs;\n};\n\n/** Per-call options for an SSE stream; reconnect defaults to true. */\nexport type SseOptions = RequestInit & { reconnect?: boolean; reconnectDelay?: number };\n\n/** A single decoded Server-Sent Event with its payload typed from the spec. */\nexport type ServerSentEvent = { event?: string; data: T; id?: string; retry?: number };\n\n/** Result-mode return shape: exactly one of `data`/`error` is set. */\nexport type Result =\n | { data: TData; error: undefined; response: Response }\n | { data: undefined; error: TError; response: Response };\n\n/**\n * The generated `Ops` type's shape: per-operation args/result, plus `kind: 'sse'` for\n * streams and, for paginated operations, `item` (the page's element type) and — on\n * result-mode clients only — `page` (the RAW page type `.pages()` yields, since\n * iteration unwraps the `Result` envelope the one-shot `result` carries).\n */\nexport type OpsShape = Record<\n string,\n { args: object; result: unknown; kind?: 'sse'; item?: unknown; page?: unknown }\n>;\n\n/** The always-present client members (assigned after the operation loop — they win collisions). */\nexport type ClientCore = {\n /** Merge into the config; note `middleware` REPLACES the chain (use `use()` to compose). */\n configure(config: ClientConfig): void;\n /** Append interceptors (composes with baked/publisher middleware). */\n use(...middleware: Middleware[]): void;\n auth: {\n bearer(token: TokenProvider): void;\n basic(username: string, password: string): void;\n apiKey(scheme: string, value: TokenProvider): void;\n };\n};\n\n/**\n * The standard TypeScript optionality probe: `{}` has no required members, so\n * `{} extends A` is true exactly when every member of `A` is optional.\n */\n// oxlint-disable-next-line typescript/no-empty-object-type\ntype NoRequiredKeys = {} extends A ? true : false;\n\n/**\n * The page type `.pages()` yields: the RAW page declared by `page` (the generator\n * writes it only on result-mode paginated entries, whose `result` is the envelope),\n * or the method's own `result` (throw mode — already the raw page).\n */\ntype PageOf = Entry extends { page: unknown }\n ? Entry['page']\n : Entry['result'];\n\n/**\n * The auto-pagination members intersected onto a paginated method — present exactly when\n * the Ops entry declares `item` (the generator writes it only for paginated operations).\n * Args optionality mirrors the method's own; `unknown` otherwise (identity under `&`).\n * Iteration is error-mode-agnostic: `.pages()`/`.items()` yield raw pages/items, and a\n * failed page aborts iteration by throwing `ApiError`, even on result-mode clients; the\n * `onError` middleware hook (throw-mode-only) is not invoked.\n */\ntype Paginated = 'item' extends keyof Entry\n ? NoRequiredKeys extends true\n ? {\n pages(args?: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args?: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : {\n pages(args: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : unknown;\n\n/**\n * The stable identity every client method carries: the SPEC operationId (also set as\n * `fn.name`, but `operationId` is the explicit, minification-proof form) — a robust\n * cache key for consumer wrappers (react-query keys and the like).\n */\nexport type OperationMethodIdentity = { readonly operationId: string };\n\n/** The typed instance client: one bound method per operation plus the core members. */\nexport type Client = {\n [K in keyof Ops]: Ops[K] extends { kind: 'sse' }\n ? (NoRequiredKeys extends true\n ? (\n args?: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>\n : (\n args: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>) &\n OperationMethodIdentity\n : (NoRequiredKeys extends true\n ? (args?: Ops[K]['args'], init?: RequestOptions) => Promise\n : (args: Ops[K]['args'], init?: RequestOptions) => Promise) &\n OperationMethodIdentity &\n Paginated;\n} & ClientCore;", + "/**\n * The public type surface of the client runtime — `@redocly/client-generator`'s\n * app-facing runtime module. Pure types, no runtime code (excluded from coverage).\n * The generator emits `OPERATIONS` literals typed\n * `satisfies Record` against this module, so an\n * incompatible runtime/generated pair fails the consumer's build (the semver skew guard).\n */\n\n/** How one operation parameter is sent: its location plus OpenAPI query-serialization hints. */\nexport type ParamSpec = {\n name: string;\n in: 'path' | 'query' | 'header' | 'cookie';\n style?: 'form' | 'spaceDelimited' | 'pipeDelimited' | 'deepObject';\n explode?: boolean;\n allowReserved?: boolean;\n};\n\n/** One security scheme, denormalized onto the operation (`scheme` names the spec's scheme). */\nexport type SecuritySpec =\n | { scheme: string; kind: 'bearer' | 'basic' }\n | { scheme: string; kind: 'apiKey'; name: string; in: 'header' | 'query' | 'cookie' };\n\n/**\n * How to auto-iterate a paginated operation (drives its `.pages()`/`.items()` members).\n * `nextCursor` and `items` are RFC 6901 JSON pointers into the page (response) value.\n */\nexport type PaginationSpec =\n | {\n style: 'cursor';\n /** The query param the iterator advances with the response's cursor. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the next cursor in the page. */\n nextCursor: string;\n /** Optional pointer to a boolean \"more pages\" flag — `false` stops iteration. */\n hasMore?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n style: 'offset' | 'page';\n /** The numeric query param the iterator advances. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n /** RFC 8288: follow the response's `Link` header `rel=\"next\"`; stop when absent. */\n style: 'link';\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n };\n\n/** The frozen data contract between generated code and the runtime: one operation's wire shape. */\nexport type OperationDescriptor = {\n id: string;\n method: string;\n path: string;\n tags?: readonly string[];\n params?: readonly ParamSpec[];\n /** `multipart: true` marks a typed object body serialized to FormData by the runtime. */\n body?: { contentType: string; multipart?: boolean };\n /** Defaults to `'json'` (content-type negotiation on parse). */\n responseKind?: 'json' | 'text' | 'blob' | 'void' | 'sse';\n sseDataKind?: 'json' | 'text';\n /** OR-alternatives, each an AND-set: the runtime applies the first fully-configured one. */\n security?: readonly (readonly SecuritySpec[])[];\n pagination?: PaginationSpec;\n /**\n * Declared success-response headers for throw-mode `{ envelope: true }`.\n * `name` is the lowercased wire name; `key` is the camelCase envelope property.\n */\n responseHeaders?: readonly ResponseHeaderSpec[];\n};\n\n/** One declared response header the runtime coerces into the envelope `headers` object. */\nexport type ResponseHeaderSpec = {\n name: string;\n key: string;\n type: 'string' | 'number' | 'boolean';\n};\n\n/** A query value: scalars, arrays of scalars, or objects (serialized as deepObject brackets). */\nexport type QueryValue =\n | string\n | number\n | boolean\n | null\n | undefined\n | Array\n | Record;\n\n/** A credential: a literal, or a (possibly async) function resolved per request (refresh flows). */\nexport type TokenProvider = string | (() => string | Promise);\n\n/** Per-instance credentials, keyed by the scheme kinds the runtime can inject. */\nexport type AuthCredentials = {\n bearer?: TokenProvider;\n basic?: { username: string; password: string };\n apiKey?: Record;\n};\n\n/** Backoff shape: 'fixed' = constant delay; 'exponential' = doubling per attempt. */\nexport type RetryStrategy = 'fixed' | 'exponential';\n\n/**\n * The operation's identity, exposed to middleware for targeting (`ctx.operation`).\n * Generated clients instantiate the type parameters with the spec's literal unions\n * (`OperationId`/`OperationPath`/`OperationTag`) so a misspelled operation id in a\n * middleware comparison fails to compile; the string defaults keep every\n * spec-independent consumer (`runtime-contract.ts`, the runtime internals) working\n * with the base shape. `tags` stays mutable (`Tag[]`) so setup-contract types\n * (byte-locked to generated output) remain assignable through middleware callbacks.\n */\nexport type OperationContext<\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n> = { id: Id; path: Path; tags: Tag[] };\n\n/** The mutable request context threaded through the middleware chain. */\nexport type RequestContext = {\n url: string;\n method: string;\n headers: Record;\n body?: unknown;\n operation: Op;\n};\n\n/** The failed attempt handed to a custom `retryOn`: exactly one of `response`/`error` is set. */\nexport type RetryContext = {\n attempt: number;\n request: RequestContext;\n response?: Response;\n error?: unknown;\n};\n\n/** Opt-in retry policy; a per-call override merges field-by-field over the config policy. */\nexport type RetryConfig = {\n retries?: number;\n retryDelay?: number;\n retryStrategy?: RetryStrategy;\n jitter?: boolean;\n retryOn?: (ctx: RetryContext) => boolean | Promise;\n};\n\n/**\n * Structural stand-in for the runtime's ApiError so this module stays import-free\n * (pure types); the real `ApiError` class is assignable to it.\n */\nexport type ApiErrorLike = globalThis.Error & {\n url: string;\n status: number;\n statusText: string;\n body: unknown;\n};\n\n/** One interceptor: any subset of the three hooks. */\nexport type Middleware = {\n onRequest?: (ctx: RequestContext) => void | Promise;\n onResponse?: (\n response: Response,\n ctx: RequestContext\n ) => Response | void | Promise;\n /** Throw mode only: may map/replace the error. */\n // `globalThis.Error` so a spec schema named `Error` cannot shadow it in inline mode.\n onError?: (\n error: ApiErrorLike,\n ctx: RequestContext\n ) => globalThis.Error | Promise;\n};\n\n/** Client configuration: transport, defaults, retry policy, middleware, and credentials. */\nexport type ClientConfig = {\n serverUrl?: string;\n fetch?: typeof fetch;\n headers?:\n | Record\n | (() => Record | Promise>);\n retry?: RetryConfig;\n /** Milliseconds before a request attempt aborts (covers the body read too; each retry\n * attempt gets a fresh budget). Per-call `timeout` overrides it, `0` disables it.\n * SSE streams are long-lived by design and never inherit this value. */\n timeout?: number;\n /** Send an `Idempotency-Key` header on POST/PATCH (one stable key per logical call,\n * reused across retry attempts) — which also makes those retries safe under the\n * default retry policy. `true` generates a UUID per call; a function supplies the key. */\n idempotencyKey?: boolean | (() => string);\n /** Identifies this client to the API via an `X-Redocly-Client` header (the generator\n * bakes a default). Sent only OUTSIDE browsers — a custom header would force a CORS\n * preflight. Override with your own value, or `false` to disable. */\n clientHeader?: string | false;\n middleware?: Middleware[];\n auth?: AuthCredentials;\n /** Fixed at generate time by the generator (`'throw'` when omitted); `configure()` ignores it. */\n errorMode?: 'throw' | 'result';\n onRequest?: Middleware['onRequest'];\n onResponse?: Middleware['onResponse'];\n onError?: Middleware['onError'];\n};\n\n/** Response readers for the per-call `parseAs` override. */\nexport type ParseAs = 'auto' | 'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData' | 'stream';\n\n/** Per-call options: standard `RequestInit` plus a retry override, a timeout override\n * (`0` disables the config default), and a forced reader. */\nexport type RequestOptions = RequestInit & {\n retry?: RetryConfig;\n timeout?: number;\n /** Per-call idempotency key: a literal key, `true` to generate one, `false` to skip. */\n idempotencyKey?: string | boolean | (() => string);\n parseAs?: ParseAs;\n /**\n * Throw mode only: return `{ data, headers, response }` instead of the parsed body;\n * ignored in result mode. The explicit `| undefined` keeps the wrappers' emitted\n * `envelope: undefined` strip legal under `exactOptionalPropertyTypes`.\n */\n envelope?: boolean | undefined;\n};\n\n/** Throw-mode success envelope when `RequestOptions.envelope` is `true`. */\nexport type Envelope> = {\n data: TData;\n headers: THeaders;\n response: Response;\n};\n\n/** Per-call options for an SSE stream; reconnect defaults to true. */\nexport type SseOptions = RequestInit & { reconnect?: boolean; reconnectDelay?: number };\n\n/** A single decoded Server-Sent Event with its payload typed from the spec. */\nexport type ServerSentEvent = { event?: string; data: T; id?: string; retry?: number };\n\n/** Result-mode return shape: exactly one of `data`/`error` is set. */\nexport type Result =\n | { data: TData; error: undefined; response: Response }\n | { data: undefined; error: TError; response: Response };\n\n/**\n * The generated `Ops` type's shape: per-operation args/result, plus `kind: 'sse'` for\n * streams and, for paginated operations, `item` (the page's element type) and — on\n * result-mode clients only — `page` (the RAW page type `.pages()` yields, since\n * iteration unwraps the `Result` envelope the one-shot `result` carries).\n */\nexport type OpsShape = Record<\n string,\n {\n args: object;\n result: unknown;\n kind?: 'sse';\n item?: unknown;\n page?: unknown;\n /** Declared success-response headers for `{ envelope: true }` (camelCase keys). */\n headers?: object;\n /** Result-mode entries ignore the throw-only `envelope` option. */\n mode?: 'result';\n }\n>;\n\n/** The always-present client members (assigned after the operation loop — they win collisions). */\nexport type ClientCore = {\n /** Merge into the config; note `middleware` REPLACES the chain (use `use()` to compose). */\n configure(config: ClientConfig): void;\n /** Append interceptors (composes with baked/publisher middleware). */\n use(...middleware: Middleware[]): void;\n auth: {\n bearer(token: TokenProvider): void;\n basic(username: string, password: string): void;\n apiKey(scheme: string, value: TokenProvider): void;\n };\n};\n\n/**\n * The standard TypeScript optionality probe: `{}` has no required members, so\n * `{} extends A` is true exactly when every member of `A` is optional.\n */\n// oxlint-disable-next-line typescript/no-empty-object-type\ntype NoRequiredKeys = {} extends A ? true : false;\n\n/**\n * The page type `.pages()` yields: the RAW page declared by `page` (the generator\n * writes it only on result-mode paginated entries, whose `result` is the envelope),\n * or the method's own `result` (throw mode — already the raw page).\n */\ntype PageOf = Entry extends { page: unknown }\n ? Entry['page']\n : Entry['result'];\n\n/**\n * The auto-pagination members intersected onto a paginated method — present exactly when\n * the Ops entry declares `item` (the generator writes it only for paginated operations).\n * Args optionality mirrors the method's own; `unknown` otherwise (identity under `&`).\n * Iteration is error-mode-agnostic: `.pages()`/`.items()` yield raw pages/items, and a\n * failed page aborts iteration by throwing `ApiError`, even on result-mode clients; the\n * `onError` middleware hook (throw-mode-only) is not invoked.\n */\ntype Paginated = 'item' extends keyof Entry\n ? NoRequiredKeys extends true\n ? {\n pages(args?: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args?: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : {\n pages(args: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : unknown;\n\n/**\n * The stable identity every client method carries: the SPEC operationId (also set as\n * `fn.name`, but `operationId` is the explicit, minification-proof form) — a robust\n * cache key for consumer wrappers (react-query keys and the like).\n */\nexport type OperationMethodIdentity = { readonly operationId: string };\n\n/** Declared response-header bag for an Ops entry; empty object when none are declared. */\ntype HeadersOf = 'headers' extends keyof Entry\n ? NonNullable\n : Record;\n\n/**\n * Return type of a throw-mode call: the body by default, `Envelope<…>` for a literal\n * `envelope: true`, their union when the flag is a widened `boolean`. Exact\n * `RequestOptions` stays the body — pre-envelope package-mode flat sugar typed every\n * `init` parameter as `RequestOptions`, and widening that would break upgrades without\n * a regenerate. The `keyof` presence gate keeps `{ headers }` / `{ signal }` as the body\n * (`TInit['envelope']` through `TInit & RequestOptions` would otherwise be\n * `boolean | undefined`).\n */\nexport type EnvelopeResult<\n TData,\n THeaders,\n TInit extends RequestOptions | undefined,\n> = TInit extends undefined\n ? TData\n : RequestOptions extends TInit\n ? TInit extends RequestOptions\n ? TData\n : EnvelopeResultForKnownInit\n : EnvelopeResultForKnownInit;\n\ntype EnvelopeResultForKnownInit = 'envelope' extends keyof TInit\n ? [TInit['envelope' & keyof TInit]] extends [true]\n ? Envelope\n : [TInit['envelope' & keyof TInit]] extends [false | undefined]\n ? TData\n : TData | Envelope\n : TData;\n\n/** A one-shot method whose return shape never varies with per-call options. */\ntype BodyMethod =\n NoRequiredKeys extends true\n ? (args?: Entry['args'], init?: RequestOptions) => Promise\n : (args: Entry['args'], init?: RequestOptions) => Promise;\n\n/**\n * One-shot (non-SSE) method: default returns the body; `{ envelope: true }` returns\n * `{ data, headers, response }` with typed declared headers.\n */\ntype ThrowMethod =\n NoRequiredKeys extends true\n ? (\n args?: Entry['args'],\n init?: Init\n ) => Promise, Init>>\n : (\n args: Entry['args'],\n init?: Init\n ) => Promise, Init>>;\n\n/** The typed instance client: one bound method per operation plus the core members. */\nexport type Client = {\n [K in keyof Ops]: Ops[K] extends { kind: 'sse' }\n ? (NoRequiredKeys extends true\n ? (\n args?: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>\n : (\n args: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>) &\n OperationMethodIdentity\n : (Ops[K] extends { mode: 'result' } ? BodyMethod : ThrowMethod) &\n OperationMethodIdentity &\n Paginated;\n} & ClientCore;", 'errors.ts': "/** The error thrown (throw mode) for a non-2xx response, carrying the decoded error body. */\nexport class ApiError extends Error {\n public readonly url: string;\n public readonly status: number;\n public readonly statusText: string;\n public readonly body: unknown;\n constructor(url: string, status: number, statusText: string, body: unknown) {\n super(`Request failed with status ${status}`);\n this.name = 'ApiError';\n this.url = url;\n this.status = status;\n this.statusText = statusText;\n this.body = body;\n }\n}\n\n/** The error thrown when a request attempt exceeds the configured `timeout` — carries\n * the context a log line needs (which operation, what budget, which attempt). */\nexport class TimeoutError extends Error {\n public readonly operationId: string;\n public readonly timeout: number;\n public readonly attempt: number;\n constructor(operationId: string, timeout: number, attempt: number) {\n super(`Request \"${operationId}\" timed out after ${timeout} ms (attempt ${attempt})`);\n this.name = 'TimeoutError';\n this.operationId = operationId;\n this.timeout = timeout;\n this.attempt = attempt;\n }\n}\n\n/** The error to throw for an aborted request: the caller's abort reason when it is an Error. */\n// `globalThis.Error` (not bare `Error`) so a spec schema named `Error` cannot shadow it\n// when this module is embedded alongside generated types (inline mode).\nfunction abortError(signal: AbortSignal): globalThis.Error {\n const reason = (signal as { reason?: unknown }).reason;\n if (reason instanceof Error) return reason;\n return new DOMException('The operation was aborted.', 'AbortError');\n}", 'url.ts': @@ -51,7 +51,7 @@ export const RUNTIME_SOURCES_STRIPPED = { 'sse.ts': "/**\n * A frame delimiter: two consecutive line terminators (each CR, LF, or CRLF, per the SSE\n * spec — so mixed endings like `\\n\\r\\n` are valid boundaries, not just matching pairs).\n */\nconst FRAME_DELIMITER = /(?:\\r\\n|\\r|\\n){2}/;\n\n/**\n * A terminally malformed event stream — unparseable JSON `data` or an unbounded frame.\n * A stable bad payload, not a dropped connection, so the stream never reconnects on it.\n */\nclass SseParseError extends Error {}\n\n/**\n * Consume a `text/event-stream` operation as typed events (capability module — wired\n * into `createClient`). Auto-reconnects on dropped connections, resuming from the last\n * seen event id via `Last-Event-ID` (backoff: the server's `retry:` value, then\n * `reconnectDelay`, then 1s — exponential with jitter, capped at 30s). A clean stream\n * end flushes a trailing frame and finishes; `break`/abort end the iterator cleanly.\n */\nasync function* sse(\n config: ClientConfig,\n op: OperationContext,\n prepare: () => Promise<{ url: string; init: SseOptions; body?: unknown }>,\n dataKind: 'json' | 'text' = 'text'\n): AsyncGenerator> {\n let lastEventId: string | undefined;\n let serverRetry: number | undefined;\n let failures = 0;\n while (true) {\n // Re-prepare each attempt so a refresh-style TokenProvider yields a fresh credential\n // on reconnect (the auth is baked into `url` query + `init.headers`). `reconnect`,\n // `reconnectDelay`, and `signal` come from the caller's original options unchanged.\n const { url, init, body: requestBody } = await prepare();\n const { reconnect = true, reconnectDelay, ...rest } = init;\n const signal = rest.signal ?? undefined;\n if (signal?.aborted) return;\n const headers: Record = {\n Accept: 'text/event-stream',\n ...toHeaderRecord(rest.headers),\n };\n const sendHeaders =\n lastEventId === undefined ? headers : { ...headers, 'Last-Event-ID': lastEventId };\n try {\n const { response } = await send(\n config,\n op,\n url,\n // `timeout: 0` opts the stream out of a config-level timeout — an event stream\n // is long-lived by design and must not be severed after N milliseconds.\n { ...rest, method: rest.method ?? 'GET', headers: sendHeaders, timeout: 0 },\n requestBody,\n undefined,\n {}\n );\n if (!response.ok) {\n const errorBody = await readError(response);\n throw new ApiError(url, response.status, response.statusText, errorBody);\n }\n failures = 0;\n const body = response.body;\n if (!body) return;\n const reader = body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n try {\n while (true) {\n const { done, value } = await reader.read();\n buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });\n let match: RegExpExecArray | null;\n while ((match = FRAME_DELIMITER.exec(buffer)) !== null) {\n const raw = buffer.slice(0, match.index);\n buffer = buffer.slice(match.index + match[0].length);\n const event = parseSseFrame(raw, dataKind);\n if (event) {\n if (event.id !== undefined) lastEventId = event.id;\n if (event.retry !== undefined) serverRetry = event.retry;\n yield event as ServerSentEvent;\n }\n }\n if (done) {\n // Stream closed cleanly. Flush a final event that arrived without a trailing\n // delimiter, then finish — a clean end is not a dropped connection, so do not reconnect.\n const event = buffer.length > 0 ? parseSseFrame(buffer, dataKind) : undefined;\n if (event) {\n if (event.id !== undefined) lastEventId = event.id;\n if (event.retry !== undefined) serverRetry = event.retry;\n yield event as ServerSentEvent;\n }\n return;\n }\n // Bound memory: a server that never sends a frame delimiter would otherwise\n // grow `buffer` without limit. 1 MiB is far above any real SSE frame.\n if (buffer.length > 1048576) {\n throw new SseParseError('SSE frame exceeded 1048576 characters without a delimiter');\n }\n }\n } finally {\n await reader.cancel().catch(() => undefined);\n }\n } catch (error) {\n if (signal?.aborted) return;\n // A non-OK HTTP response (4xx/5xx) or an unparseable JSON payload is a definitive\n // error, not a transient drop — surface it instead of reconnecting in a loop (a\n // stable bad payload would otherwise reconnect forever).\n if (error instanceof ApiError || error instanceof SseParseError) throw error;\n // A transport failure (connect/DNS/reset) when opening the request, or a mid-stream\n // read error, is a dropped connection: fall through to backoff/reconnect when enabled.\n if (!reconnect) throw error;\n }\n // Only the swallowed-drop path reaches here: reconnect is on and the signal not aborted.\n failures++;\n const base = serverRetry ?? reconnectDelay ?? 1000;\n const delay = Math.min(base * Math.pow(2, failures - 1), 30_000);\n try {\n await sleep(Math.random() * delay, signal);\n } catch {\n return; // sleep rejects only on abort — end the iterator cleanly\n }\n }\n}\n\n/** Parse one raw SSE frame (its lines) into an event; returns undefined for comment-only frames. */\nfunction parseSseFrame(\n raw: string,\n dataKind: 'json' | 'text'\n): ServerSentEvent | undefined {\n let event: string | undefined;\n const dataLines: string[] = [];\n let id: string | undefined;\n let retry: number | undefined;\n let sawField = false;\n for (const line of raw.split(/\\r\\n|\\n|\\r/)) {\n if (line === '' || line.startsWith(':')) continue;\n const colon = line.indexOf(':');\n const field = colon === -1 ? line : line.slice(0, colon);\n let val = colon === -1 ? '' : line.slice(colon + 1);\n if (val.startsWith(' ')) val = val.slice(1);\n sawField = true;\n if (field === 'event') event = val;\n else if (field === 'data') dataLines.push(val);\n else if (field === 'id') id = val;\n else if (field === 'retry') {\n // ASCII digits only, per the EventSource spec — anything else is ignored\n // (`Number('')` is 0 and would zero the reconnect backoff).\n if (/^\\d+$/.test(val)) retry = Number(val);\n }\n }\n if (!sawField) return undefined;\n const dataText = dataLines.join('\\n');\n let data: unknown = dataText;\n if (dataKind === 'json' && dataText !== '') {\n try {\n data = JSON.parse(dataText);\n } catch (error) {\n throw new SseParseError(\n `Failed to parse SSE event data as JSON: ${error instanceof Error ? error.message : String(error)}`\n );\n }\n }\n return { event, data, id, retry };\n}", 'create-client.ts': - "/**\n * The optional behaviors `createClientCore` can dispatch to but never statically\n * imports. The package's public `createClient` wires the full set; the future\n * inline-mode assembler wires only the capabilities a spec needs.\n */\ntype Capabilities = SendCapabilities & {\n resolveAuth?: (\n security: readonly (readonly SecuritySpec[])[],\n config: ClientConfig\n ) => Promise<{ headers: Record; query: Record }>;\n sse?: (\n config: ClientConfig,\n op: OperationContext,\n // Re-preparing per (re)connect (not a frozen url/init) lets a refresh-style\n // TokenProvider issue a fresh credential after a dropped stream reconnects.\n prepare: () => Promise<{ url: string; init: SseOptions }>,\n dataKind: 'json' | 'text'\n ) => AsyncGenerator>;\n paginate?: {\n pages: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n items: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n // The `link`-style iterators need the raw `Link` header + page URL, which the\n // parsed-page call above cannot carry (the shape mirrors paginate's `LinkPageCall`).\n pagesByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n itemsByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n };\n};\n\n/** The grouped args wire shape: path params by name plus the `params`/`body`/`headers`/`cookies` slots. */\ntype OperationArgs = {\n params?: Record;\n body?: unknown;\n headers?: Record;\n cookies?: Record;\n} & Record;\n\n/** The response reader implied by the descriptor (before any per-call `parseAs` override). */\n/**\n * The `Accept` header matching how the response will be read — a blob/text operation\n * must not ask for `application/json` (a content-negotiating server would 406 or\n * answer with a JSON error body instead of the payload). Caller `init.headers` and\n * `config.headers` still override.\n */\nfunction acceptFor(kind: ParseAs | 'void'): string {\n if (kind === 'text') return 'text/*';\n if (kind === 'blob' || kind === 'arrayBuffer' || kind === 'stream' || kind === 'formData') {\n return '*/*';\n }\n return 'application/json'; // json | auto | void\n}\n\nfunction kindFor(op: OperationDescriptor): ParseAs | 'void' {\n if (op.responseKind === 'void' || op.responseKind === 'blob' || op.responseKind === 'text') {\n return op.responseKind;\n }\n return 'auto';\n}\n\n/** Route the grouped args by the descriptor: path values, query object, body, extra headers, cookies. */\nfunction splitArgs(op: OperationDescriptor, args: OperationArgs) {\n const path: Record = {};\n const pathNames = new Set();\n for (const param of op.params ?? []) {\n if (param.in === 'path') {\n pathNames.add(param.name);\n path[param.name] = args[param.name];\n }\n }\n // An unknown top-level key can only be a bug (usually a flat-style call shape passed\n // to a grouped client: `{ limit: 10 }` instead of `{ params: { limit: 10 } }`).\n // TypeScript catches it, but transpilers that skip type-checking would otherwise\n // ship a request that silently drops the value — fail the call loudly instead.\n for (const key of Object.keys(args)) {\n if (key === 'params' || key === 'body' || key === 'headers' || key === 'cookies') continue;\n if (pathNames.has(key)) continue;\n throw new TypeError(\n `Unknown argument \"${key}\" for operation \"${op.id}\". Query parameters go under params: { … } and the request body under body; valid keys are params, body, headers, cookies` +\n (pathNames.size > 0 ? `, and the path parameters (${[...pathNames].join(', ')}).` : '.')\n );\n }\n return {\n path,\n query: args.params,\n body: args.body,\n headers: args.headers,\n cookies: args.cookies,\n };\n}\n\n/**\n * The query-serialization hints for the descriptor's query params. A spec is built only\n * when the param deviates from the OpenAPI defaults (`form` + `explode: true`, encoded),\n * and always fully resolved — so `explode: false` or `allowReserved` alone (no `style`)\n * are honored, and an omitted `explode` keeps the exploded default.\n */\nfunction queryStyles(op: OperationDescriptor): Record | undefined {\n let styles: Record | undefined;\n for (const param of op.params ?? []) {\n if (param.in !== 'query') continue;\n const deviates =\n (param.style !== undefined && param.style !== 'form') ||\n param.explode === false ||\n param.allowReserved === true;\n if (!deviates) continue;\n styles ??= {};\n styles[param.name] = {\n style: param.style ?? 'form',\n explode: param.explode ?? true,\n allowReserved: param.allowReserved,\n };\n }\n return styles;\n}\n\n/** Stringify caller-supplied extra headers, skipping empty entries. */\nfunction stringHeaders(headers: Record | undefined): Record {\n const out: Record = {};\n for (const [key, value] of Object.entries(headers ?? {})) {\n if (value !== undefined && value !== null) out[key] = String(value);\n }\n return out;\n}\n\n/** Build the request pieces an attempt needs: the final URL and the merged per-call init. */\nasync function prepareRequest(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions | SseOptions,\n caps: Capabilities\n): Promise<{ url: string; init: RequestOptions; body: unknown }> {\n const { path, query, body, headers, cookies } = splitArgs(op, args);\n const authed: { headers: Record; query: Record } =\n op.security?.length && caps.resolveAuth\n ? await caps.resolveAuth(op.security, config)\n : { headers: {}, query: {} };\n // Cookie params join the auth-injected cookies in one `Cookie` header (values\n // percent-encoded, like auth cookies). Server-side only — browsers own the header.\n const cookiePairs = Object.entries(cookies ?? {})\n .filter(([, value]) => value !== undefined && value !== null)\n .map(([cookieName, value]) => `${cookieName}=${encodeURIComponent(String(value))}`);\n if (cookiePairs.length > 0) {\n authed.headers.Cookie = [authed.headers.Cookie, ...cookiePairs].filter(Boolean).join('; ');\n }\n const fullQuery: Record = { ...query, ...authed.query };\n const url = buildUrl(\n config.serverUrl ?? '',\n substitutePath(op.path, path),\n Object.keys(fullQuery).length > 0 ? fullQuery : undefined,\n queryStyles(op)\n );\n const mergedInit: RequestOptions = {\n ...init,\n method: op.method.toUpperCase(),\n // Precedence, lowest → highest (later spreads win): injected auth → explicit\n // header params → caller `init.headers` — the caller always overrides both.\n headers: {\n ...authed.headers,\n ...stringHeaders(headers),\n ...toHeaderRecord(init.headers),\n },\n };\n return { url, init: mergedInit, body };\n}\n\n/** One non-SSE call: send, then branch on the configured error mode. */\nasync function execute(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions,\n caps: Capabilities\n): Promise {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n const { parseAs, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const { response, context } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (config.errorMode === 'result') {\n if (!response.ok) {\n return { data: undefined, error: await readError(response), response };\n }\n return { data: await parse(response, readKind), error: undefined, response };\n }\n if (!response.ok) {\n let error: globalThis.Error = new ApiError(\n context.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n // Thread the error through each middleware's onError in turn (each may replace it).\n for (const mw of middlewareChain(config)) {\n if (mw.onError) error = await mw.onError(error as ApiErrorLike, context);\n }\n throw error;\n }\n return parse(response, readKind);\n}\n\n/** The paginate capability, or a descriptive throw when a paginated op is iterated unwired. */\nfunction paginateCapability(caps: Capabilities, op: OperationDescriptor) {\n if (!caps.paginate) {\n throw new Error(`Pagination capability not wired: cannot iterate operation \"${op.id}\"`);\n }\n return caps.paginate;\n}\n\n/**\n * The per-page call the iterators drive: the method itself in throw mode; in result\n * mode a wrapper that unwraps the `{ data, error, response }` envelope — the page\n * pointers are data-rooted — rethrowing a failed page as `ApiError` (iteration is\n * error-mode-agnostic; the throw-mode-only `onError` middleware hook is not invoked).\n */\nfunction pageCall(\n method: (args?: OperationArgs, init?: RequestOptions) => Promise,\n config: ClientConfig\n) {\n if (config.errorMode !== 'result') return method;\n return async (args?: OperationArgs, init?: RequestOptions) => {\n const envelope = (await method(args, init)) as {\n data: unknown;\n error: unknown;\n response: Response;\n };\n // Failure is `!response.ok` — NOT `data === undefined`: a successful bodyless page\n // (204/void) also parses to undefined data, and a failed page's `error` can be\n // undefined too (unreadable body). The pointers then miss on the undefined data\n // and iteration stops cleanly, which is the correct semantics for an empty page.\n if (!envelope.response.ok) {\n const { response } = envelope;\n throw new ApiError(response.url, response.status, response.statusText, envelope.error);\n }\n return envelope.data;\n };\n}\n\n/**\n * The per-page call the `link`-style iterators drive: like `execute`, but returning the\n * parsed page together with the raw `Link` header and the page's own URL (for resolving\n * a relative `rel=\"next\"` target). Error-mode-agnostic like all iteration: a failed\n * page throws `ApiError` even on result-mode clients.\n */\nfunction linkPageCall(config: ClientConfig, op: OperationDescriptor, caps: Capabilities) {\n return async (args: OperationArgs = {}, init: RequestOptions = {}) => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const { parseAs, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n const { response } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (!response.ok) {\n throw new ApiError(\n prepared.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n }\n return {\n page: await parse(response, readKind),\n linkHeader: response.headers.get('link'),\n // Some `Response` implementations leave `url` empty (mocks, constructed responses).\n url: response.url === '' ? prepared.url : response.url,\n };\n };\n}\n\n/**\n * Build a typed instance client over operation descriptors: one real bound method per\n * operation (attached by a construction-time loop — no Proxy), plus the core members\n * (`configure`/`use`/`auth`), which are assigned AFTER the loop so they win any name\n * collision with an operation. All behavior dispatches through the capability seam.\n */\nfunction createClientCore<\n Ops extends OpsShape,\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n>(\n operations: Record,\n initial: ClientConfig> = {},\n caps: Capabilities = {}\n): Client> {\n // The literal-union narrowing is a compile-time DX contract only; internally the\n // runtime works with the base (string-typed) context. One cast at this boundary —\n // `ClientConfig` is not assignable to `ClientConfig` (middleware ctx\n // params are contravariant).\n const given = initial as ClientConfig;\n // Private mutable config; the middleware array is copied so `use()` never mutates the caller's.\n const config: ClientConfig = { ...given, middleware: [...(given.middleware ?? [])] };\n const client = {} as Record;\n\n for (const [name, op] of Object.entries(operations)) {\n if (op.responseKind === 'sse') {\n const method = (args: OperationArgs = {}, init: SseOptions = {}) => {\n if (!caps.sse) {\n throw new Error(`SSE capability not wired: cannot stream operation \"${op.id}\"`);\n }\n const stream = caps.sse;\n return (async function* () {\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // A thunk the stream re-runs on every (re)connect, so auth (which `prepareRequest`\n // resolves) is refreshed per attempt rather than frozen at the first connect.\n const prepare = async () => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n return { url: prepared.url, init: prepared.init as SseOptions, body: prepared.body };\n };\n yield* stream(config, opCtx, prepare, op.sseDataKind ?? 'text');\n })();\n };\n // Consumers key off the function reference (cache keys, `OPERATIONS[fn.name]`), so\n // each closure carries its operationId instead of an inferred binding name.\n // `operationId` is the explicit, minification-proof form of the same identity\n // (the SPEC operationId — `name` is the emitted key, which a collision may rename).\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n client[name] = method;\n } else {\n const method = (args: OperationArgs = {}, init: RequestOptions = {}) =>\n execute(config, op, args, init, caps);\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n const spec = op.pagination;\n // Paginated ops keep their one-shot call and gain `.pages`/`.items`, dispatching\n // through the capability seam (like SSE: absent capability throws descriptively).\n // Iteration is error-mode-agnostic: the iterators' pointers are data-rooted, so on\n // a result-mode client (`errorMode` is fixed at construction — `configure()`\n // ignores it) each page's envelope is unwrapped before it reaches the capability.\n // A failed page aborts iteration by throwing ApiError, even on result-mode\n // clients; the `onError` middleware hook (throw-mode-only) is not invoked.\n client[name] =\n spec === undefined\n ? method\n : spec.style === 'link'\n ? Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pagesByLink(\n linkPageCall(config, op, caps),\n args,\n init\n ),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).itemsByLink(\n linkPageCall(config, op, caps),\n spec,\n args,\n init\n ),\n })\n : Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pages(pageCall(method, config), spec, args, init),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).items(pageCall(method, config), spec, args, init),\n });\n }\n }\n\n // Core members are assigned AFTER the operation loop — they win over colliding op names.\n client.configure = (next: ClientConfig): void => {\n // `errorMode` is fixed at generate time (it shapes the static types); flipping it at\n // runtime would silently desync return shapes from `Client`, so it is ignored.\n const { errorMode: _fixed, auth, ...rest } = next;\n Object.assign(config, rest);\n // `auth` merges into existing credentials (like the `auth.*` setters) rather than\n // replacing wholesale — so `configure({ auth: { bearer } })` keeps a previously set\n // basic/apiKey. `apiKey` merges per scheme.\n if (auth) {\n config.auth = {\n ...config.auth,\n ...auth,\n ...(auth.apiKey ? { apiKey: { ...config.auth?.apiKey, ...auth.apiKey } } : {}),\n };\n }\n };\n client.use = (...middleware: Middleware[]): void => {\n // Reassign (don't push) so a caller-provided `middleware` array isn't mutated.\n config.middleware = [...(config.middleware ?? []), ...middleware];\n };\n client.auth = {\n bearer(token: TokenProvider): void {\n config.auth = { ...config.auth, bearer: token };\n },\n basic(username: string, password: string): void {\n config.auth = { ...config.auth, basic: { username, password } };\n },\n apiKey(scheme: string, value: TokenProvider): void {\n config.auth = { ...config.auth, apiKey: { ...config.auth?.apiKey, [scheme]: value } };\n },\n };\n\n return client as Client>;\n}", + "/**\n * The optional behaviors `createClientCore` can dispatch to but never statically\n * imports. The package's public `createClient` wires the full set; the future\n * inline-mode assembler wires only the capabilities a spec needs.\n */\ntype Capabilities = SendCapabilities & {\n resolveAuth?: (\n security: readonly (readonly SecuritySpec[])[],\n config: ClientConfig\n ) => Promise<{ headers: Record; query: Record }>;\n sse?: (\n config: ClientConfig,\n op: OperationContext,\n // Re-preparing per (re)connect (not a frozen url/init) lets a refresh-style\n // TokenProvider issue a fresh credential after a dropped stream reconnects.\n prepare: () => Promise<{ url: string; init: SseOptions }>,\n dataKind: 'json' | 'text'\n ) => AsyncGenerator>;\n paginate?: {\n pages: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n items: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n // The `link`-style iterators need the raw `Link` header + page URL, which the\n // parsed-page call above cannot carry (the shape mirrors paginate's `LinkPageCall`).\n pagesByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n itemsByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n };\n};\n\n/** The grouped args wire shape: path params by name plus the `params`/`body`/`headers`/`cookies` slots. */\ntype OperationArgs = {\n params?: Record;\n body?: unknown;\n headers?: Record;\n cookies?: Record;\n} & Record;\n\n/** The response reader implied by the descriptor (before any per-call `parseAs` override). */\n/**\n * The `Accept` header matching how the response will be read — a blob/text operation\n * must not ask for `application/json` (a content-negotiating server would 406 or\n * answer with a JSON error body instead of the payload). Caller `init.headers` and\n * `config.headers` still override.\n */\nfunction acceptFor(kind: ParseAs | 'void'): string {\n if (kind === 'text') return 'text/*';\n if (kind === 'blob' || kind === 'arrayBuffer' || kind === 'stream' || kind === 'formData') {\n return '*/*';\n }\n return 'application/json'; // json | auto | void\n}\n\nfunction kindFor(op: OperationDescriptor): ParseAs | 'void' {\n if (op.responseKind === 'void' || op.responseKind === 'blob' || op.responseKind === 'text') {\n return op.responseKind;\n }\n return 'auto';\n}\n\n/** Route the grouped args by the descriptor: path values, query object, body, extra headers, cookies. */\nfunction splitArgs(op: OperationDescriptor, args: OperationArgs) {\n const path: Record = {};\n const pathNames = new Set();\n for (const param of op.params ?? []) {\n if (param.in === 'path') {\n pathNames.add(param.name);\n path[param.name] = args[param.name];\n }\n }\n // An unknown top-level key can only be a bug (usually a flat-style call shape passed\n // to a grouped client: `{ limit: 10 }` instead of `{ params: { limit: 10 } }`).\n // TypeScript catches it, but transpilers that skip type-checking would otherwise\n // ship a request that silently drops the value — fail the call loudly instead.\n for (const key of Object.keys(args)) {\n if (key === 'params' || key === 'body' || key === 'headers' || key === 'cookies') continue;\n if (pathNames.has(key)) continue;\n throw new TypeError(\n `Unknown argument \"${key}\" for operation \"${op.id}\". Query parameters go under params: { … } and the request body under body; valid keys are params, body, headers, cookies` +\n (pathNames.size > 0 ? `, and the path parameters (${[...pathNames].join(', ')}).` : '.')\n );\n }\n return {\n path,\n query: args.params,\n body: args.body,\n headers: args.headers,\n cookies: args.cookies,\n };\n}\n\n/**\n * The query-serialization hints for the descriptor's query params. A spec is built only\n * when the param deviates from the OpenAPI defaults (`form` + `explode: true`, encoded),\n * and always fully resolved — so `explode: false` or `allowReserved` alone (no `style`)\n * are honored, and an omitted `explode` keeps the exploded default.\n */\nfunction queryStyles(op: OperationDescriptor): Record | undefined {\n let styles: Record | undefined;\n for (const param of op.params ?? []) {\n if (param.in !== 'query') continue;\n const deviates =\n (param.style !== undefined && param.style !== 'form') ||\n param.explode === false ||\n param.allowReserved === true;\n if (!deviates) continue;\n styles ??= {};\n styles[param.name] = {\n style: param.style ?? 'form',\n explode: param.explode ?? true,\n allowReserved: param.allowReserved,\n };\n }\n return styles;\n}\n\n/** Stringify caller-supplied extra headers, skipping empty entries. */\nfunction stringHeaders(headers: Record | undefined): Record {\n const out: Record = {};\n for (const [key, value] of Object.entries(headers ?? {})) {\n if (value !== undefined && value !== null) out[key] = String(value);\n }\n return out;\n}\n\n/** Build the request pieces an attempt needs: the final URL and the merged per-call init. */\nasync function prepareRequest(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions | SseOptions,\n caps: Capabilities\n): Promise<{ url: string; init: RequestOptions; body: unknown }> {\n const { path, query, body, headers, cookies } = splitArgs(op, args);\n const authed: { headers: Record; query: Record } =\n op.security?.length && caps.resolveAuth\n ? await caps.resolveAuth(op.security, config)\n : { headers: {}, query: {} };\n // Cookie params join the auth-injected cookies in one `Cookie` header (values\n // percent-encoded, like auth cookies). Server-side only — browsers own the header.\n const cookiePairs = Object.entries(cookies ?? {})\n .filter(([, value]) => value !== undefined && value !== null)\n .map(([cookieName, value]) => `${cookieName}=${encodeURIComponent(String(value))}`);\n if (cookiePairs.length > 0) {\n authed.headers.Cookie = [authed.headers.Cookie, ...cookiePairs].filter(Boolean).join('; ');\n }\n const fullQuery: Record = { ...query, ...authed.query };\n const url = buildUrl(\n config.serverUrl ?? '',\n substitutePath(op.path, path),\n Object.keys(fullQuery).length > 0 ? fullQuery : undefined,\n queryStyles(op)\n );\n const mergedInit: RequestOptions = {\n ...init,\n method: op.method.toUpperCase(),\n // Precedence, lowest → highest (later spreads win): injected auth → explicit\n // header params → caller `init.headers` — the caller always overrides both.\n headers: {\n ...authed.headers,\n ...stringHeaders(headers),\n ...toHeaderRecord(init.headers),\n },\n };\n return { url, init: mergedInit, body };\n}\n\n/** Coerce a single declared response header value; omit when absent or unparsable. */\nfunction coerceResponseHeader(\n raw: string | null,\n type: ResponseHeaderSpec['type']\n): string | number | boolean | undefined {\n if (raw === null) return undefined;\n if (type === 'number') {\n if (raw.trim() === '') return undefined;\n const value = Number(raw);\n return Number.isFinite(value) ? value : undefined;\n }\n if (type === 'boolean') {\n const value = raw.trim().toLowerCase();\n if (value === 'true') return true;\n if (value === 'false') return false;\n return undefined;\n }\n return raw;\n}\n\n/** Build the camelCase declared-header bag for a throw-mode envelope. */\nfunction readEnvelopeHeaders(\n response: Response,\n specs: readonly ResponseHeaderSpec[] | undefined\n): Record {\n const headers: Record = {};\n for (const spec of specs ?? []) {\n const value = coerceResponseHeader(response.headers.get(spec.name), spec.type);\n if (value !== undefined) headers[spec.key] = value;\n }\n return headers;\n}\n\n/** One non-SSE call: send, then branch on the configured error mode. */\nasync function execute(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions,\n caps: Capabilities\n): Promise {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // `parseAs` / `envelope` are client options, not fetch RequestInit fields.\n const { parseAs, envelope, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const { response, context } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (config.errorMode === 'result') {\n if (!response.ok) {\n return { data: undefined, error: await readError(response), response };\n }\n return { data: await parse(response, readKind), error: undefined, response };\n }\n if (!response.ok) {\n let error: globalThis.Error = new ApiError(\n context.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n // Thread the error through each middleware's onError in turn (each may replace it).\n for (const mw of middlewareChain(config)) {\n if (mw.onError) error = await mw.onError(error as ApiErrorLike, context);\n }\n throw error;\n }\n const data = await parse(response, readKind);\n if (envelope === true) {\n return {\n data,\n headers: readEnvelopeHeaders(response, op.responseHeaders),\n response,\n };\n }\n return data;\n}\n\n/** The paginate capability, or a descriptive throw when a paginated op is iterated unwired. */\nfunction paginateCapability(caps: Capabilities, op: OperationDescriptor) {\n if (!caps.paginate) {\n throw new Error(`Pagination capability not wired: cannot iterate operation \"${op.id}\"`);\n }\n return caps.paginate;\n}\n\n/**\n * The per-page call the iterators drive: the method itself in throw mode; in result\n * mode a wrapper that unwraps the `{ data, error, response }` envelope — the page\n * pointers are data-rooted — rethrowing a failed page as `ApiError` (iteration is\n * error-mode-agnostic; the throw-mode-only `onError` middleware hook is not invoked).\n */\nfunction pageCall(\n method: (args?: OperationArgs, init?: RequestOptions) => Promise,\n config: ClientConfig\n) {\n const callWithoutEnvelope = (args?: OperationArgs, init?: RequestOptions) => {\n if (!init || init.envelope === undefined) return method(args, init);\n const { envelope: _envelope, ...pageInit } = init;\n return method(args, pageInit);\n };\n if (config.errorMode !== 'result') return callWithoutEnvelope;\n return async (args?: OperationArgs, init?: RequestOptions) => {\n const envelope = (await callWithoutEnvelope(args, init)) as {\n data: unknown;\n error: unknown;\n response: Response;\n };\n // Failure is `!response.ok` — NOT `data === undefined`: a successful bodyless page\n // (204/void) also parses to undefined data, and a failed page's `error` can be\n // undefined too (unreadable body). The pointers then miss on the undefined data\n // and iteration stops cleanly, which is the correct semantics for an empty page.\n if (!envelope.response.ok) {\n const { response } = envelope;\n throw new ApiError(response.url, response.status, response.statusText, envelope.error);\n }\n return envelope.data;\n };\n}\n\n/**\n * The per-page call the `link`-style iterators drive: like `execute`, but returning the\n * parsed page together with the raw `Link` header and the page's own URL (for resolving\n * a relative `rel=\"next\"` target). Error-mode-agnostic like all iteration: a failed\n * page throws `ApiError` even on result-mode clients.\n */\nfunction linkPageCall(config: ClientConfig, op: OperationDescriptor, caps: Capabilities) {\n return async (args: OperationArgs = {}, init: RequestOptions = {}) => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const { parseAs, envelope: _envelope, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n const { response } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (!response.ok) {\n throw new ApiError(\n prepared.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n }\n return {\n page: await parse(response, readKind),\n linkHeader: response.headers.get('link'),\n // Some `Response` implementations leave `url` empty (mocks, constructed responses).\n url: response.url === '' ? prepared.url : response.url,\n };\n };\n}\n\n/**\n * Build a typed instance client over operation descriptors: one real bound method per\n * operation (attached by a construction-time loop — no Proxy), plus the core members\n * (`configure`/`use`/`auth`), which are assigned AFTER the loop so they win any name\n * collision with an operation. All behavior dispatches through the capability seam.\n */\nfunction createClientCore<\n Ops extends OpsShape,\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n>(\n operations: Record,\n initial: ClientConfig> = {},\n caps: Capabilities = {}\n): Client> {\n // The literal-union narrowing is a compile-time DX contract only; internally the\n // runtime works with the base (string-typed) context. One cast at this boundary —\n // `ClientConfig` is not assignable to `ClientConfig` (middleware ctx\n // params are contravariant).\n const given = initial as ClientConfig;\n // Private mutable config; the middleware array is copied so `use()` never mutates the caller's.\n const config: ClientConfig = { ...given, middleware: [...(given.middleware ?? [])] };\n const client = {} as Record;\n\n for (const [name, op] of Object.entries(operations)) {\n if (op.responseKind === 'sse') {\n const method = (args: OperationArgs = {}, init: SseOptions = {}) => {\n if (!caps.sse) {\n throw new Error(`SSE capability not wired: cannot stream operation \"${op.id}\"`);\n }\n const stream = caps.sse;\n return (async function* () {\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // A thunk the stream re-runs on every (re)connect, so auth (which `prepareRequest`\n // resolves) is refreshed per attempt rather than frozen at the first connect.\n const prepare = async () => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n return { url: prepared.url, init: prepared.init as SseOptions, body: prepared.body };\n };\n yield* stream(config, opCtx, prepare, op.sseDataKind ?? 'text');\n })();\n };\n // Consumers key off the function reference (cache keys, `OPERATIONS[fn.name]`), so\n // each closure carries its operationId instead of an inferred binding name.\n // `operationId` is the explicit, minification-proof form of the same identity\n // (the SPEC operationId — `name` is the emitted key, which a collision may rename).\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n client[name] = method;\n } else {\n const method = (args: OperationArgs = {}, init: RequestOptions = {}) =>\n execute(config, op, args, init, caps);\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n const spec = op.pagination;\n // Paginated ops keep their one-shot call and gain `.pages`/`.items`, dispatching\n // through the capability seam (like SSE: absent capability throws descriptively).\n // Iteration is error-mode-agnostic: the iterators' pointers are data-rooted, so on\n // a result-mode client (`errorMode` is fixed at construction — `configure()`\n // ignores it) each page's envelope is unwrapped before it reaches the capability.\n // A failed page aborts iteration by throwing ApiError, even on result-mode\n // clients; the `onError` middleware hook (throw-mode-only) is not invoked.\n client[name] =\n spec === undefined\n ? method\n : spec.style === 'link'\n ? Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pagesByLink(\n linkPageCall(config, op, caps),\n args,\n init\n ),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).itemsByLink(\n linkPageCall(config, op, caps),\n spec,\n args,\n init\n ),\n })\n : Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pages(pageCall(method, config), spec, args, init),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).items(pageCall(method, config), spec, args, init),\n });\n }\n }\n\n // Core members are assigned AFTER the operation loop — they win over colliding op names.\n client.configure = (next: ClientConfig): void => {\n // `errorMode` is fixed at generate time (it shapes the static types); flipping it at\n // runtime would silently desync return shapes from `Client`, so it is ignored.\n const { errorMode: _fixed, auth, ...rest } = next;\n Object.assign(config, rest);\n // `auth` merges into existing credentials (like the `auth.*` setters) rather than\n // replacing wholesale — so `configure({ auth: { bearer } })` keeps a previously set\n // basic/apiKey. `apiKey` merges per scheme.\n if (auth) {\n config.auth = {\n ...config.auth,\n ...auth,\n ...(auth.apiKey ? { apiKey: { ...config.auth?.apiKey, ...auth.apiKey } } : {}),\n };\n }\n };\n client.use = (...middleware: Middleware[]): void => {\n // Reassign (don't push) so a caller-provided `middleware` array isn't mutated.\n config.middleware = [...(config.middleware ?? []), ...middleware];\n };\n client.auth = {\n bearer(token: TokenProvider): void {\n config.auth = { ...config.auth, bearer: token };\n },\n basic(username: string, password: string): void {\n config.auth = { ...config.auth, basic: { username, password } };\n },\n apiKey(scheme: string, value: TokenProvider): void {\n config.auth = { ...config.auth, apiKey: { ...config.auth?.apiKey, [scheme]: value } };\n },\n };\n\n return client as Client>;\n}", 'paginate.ts': "/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `params` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nfunction resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `params[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nasync function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.params?.[spec.param];\n while (true) {\n const params = { ...args.params };\n if (cursor !== undefined) params[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, params }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `params[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.params?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call(\n { ...args, params: { ...args.params, [spec.param]: position } },\n init\n );\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nasync function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\ntype LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nfunction linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nasync function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let params = args.params;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, params }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n params = { ...args.params, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nasync function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}", 'cli.ts': @@ -66,6 +66,7 @@ export const RUNTIME_DECLARED_NAMES = [ 'ApiError', 'ApiErrorLike', 'AuthCredentials', + 'BodyMethod', 'Capabilities', 'CliAuthScheme', 'CliCommand', @@ -76,8 +77,12 @@ export const RUNTIME_DECLARED_NAMES = [ 'Client', 'ClientConfig', 'ClientCore', + 'Envelope', + 'EnvelopeResult', + 'EnvelopeResultForKnownInit', 'FRAME_DELIMITER', 'GLOBAL_FLAGS', + 'HeadersOf', 'IDEMPOTENT_METHODS', 'LinkPageCall', 'Middleware', @@ -96,6 +101,7 @@ export const RUNTIME_DECLARED_NAMES = [ 'QueryValue', 'RequestContext', 'RequestOptions', + 'ResponseHeaderSpec', 'Result', 'RetryConfig', 'RetryContext', @@ -106,11 +112,13 @@ export const RUNTIME_DECLARED_NAMES = [ 'SseOptions', 'SseParseError', 'TRANSIENT_STATUS', + 'ThrowMethod', 'TimeoutError', 'TokenProvider', 'abortError', 'acceptFor', 'buildUrl', + 'coerceResponseHeader', 'createClientCore', 'defaultRetryOn', 'encodeBase64', @@ -135,6 +143,7 @@ export const RUNTIME_DECLARED_NAMES = [ 'parseSseFrame', 'prepareRequest', 'queryStyles', + 'readEnvelopeHeaders', 'readError', 'redactHeaders', 'renderHelp', diff --git a/packages/client-generator/src/emitters/swr.ts b/packages/client-generator/src/emitters/swr.ts index da48c719b1..9ff2a142e2 100644 --- a/packages/client-generator/src/emitters/swr.ts +++ b/packages/client-generator/src/emitters/swr.ts @@ -56,9 +56,10 @@ function queryBlocks(op: OperationModel, opts: SwrOptions): string[] { const key = `export const ${op.name}Key = (${keyParams}) => ${keyElements} as const;`; const keyCall = `${op.name}Key(${inputs ? 'vars' : ''})`; const useSwr = `useSWR(${keyCall}, () => ${sdkCallText(op, opts.argsStyle, 'vars', true)})`; + // The throw-only `envelope` option is excluded — cached data must stay the plain body. const params = inputs - ? `vars: ${variablesName(op)}, init?: RequestOptions` - : 'init?: RequestOptions'; + ? `vars: ${variablesName(op)}, init?: Omit` + : 'init?: Omit'; return [key, hookBlock(op, params, useSwr)]; } diff --git a/packages/client-generator/src/emitters/wrapper-support.ts b/packages/client-generator/src/emitters/wrapper-support.ts index 3da14cfc4c..cae2b4e22d 100644 --- a/packages/client-generator/src/emitters/wrapper-support.ts +++ b/packages/client-generator/src/emitters/wrapper-support.ts @@ -67,9 +67,11 @@ export function variablesName(op: OperationModel): string { /** The forwarding-call ARGUMENT LIST to the sdk operation function, as text. Argument * order comes from the shared `operationSignature`, so it lines up with the sdk's - * parameter list by construction. `grouped` passes the source object (when inputs); + * parameter list by construction. `grouped` passes the source object — `{}` for a + * no-input op with an init, which must not land in the `(args?, init?)` args slot; * `flat` spreads `.` (URL-template order), then the slots the op - * has. `init` is appended last when `withInit`. */ + * has. `withInit` appends `{ ...init, envelope: undefined }` — a runtime strip, since + * the wrappers cache the fetched body and their `Omit`-typed init is type-only. */ export function sdkCallText( op: OperationModel, argsStyle: 'flat' | 'grouped', @@ -80,6 +82,7 @@ export function sdkCallText( const args: string[] = []; if (argsStyle === 'grouped') { if (sig.hasInputs) args.push(source); + else if (withInit) args.push('{}'); } else { for (const { ident } of sig.pathParams) args.push(`${source}.${ident}`); if (sig.hasQuery) args.push(`${source}.params`); @@ -87,7 +90,7 @@ export function sdkCallText( if (sig.hasHeaders) args.push(`${source}.headers`); if (sig.hasCookies) args.push(`${source}.cookies`); } - if (withInit) args.push('init'); + if (withInit) args.push('{ ...init, envelope: undefined }'); return `${op.name}(${args.join(', ')})`; } From 912d2cb9522593b7dfeb440e7435f1ce546bf847 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 4 Aug 2026 17:00:16 +0300 Subject: [PATCH 074/211] feat: harden the custom-generator contract (output containment, run() shape validation, GENERATOR_CONTRACT handshake, IR shape tripwire) --- .../@v2/guides/customize-client-generation.md | 3 + .../client-generator/eject-assets/AGENTS.md | 7 +- .../scripts/generate-eject-assets.mjs | 11 +- .../src/__tests__/index.test.ts | 47 ++++ .../src/generators/__tests__/resolve.test.ts | 21 ++ .../src/generators/contract.ts | 12 ++ .../src/generators/resolve.ts | 11 + .../client-generator/src/generators/types.ts | 7 + .../__snapshots__/contract-shape.test.ts.snap | 201 ++++++++++++++++++ .../__tests__/contract-shape.test.ts | 95 +++++++++ packages/client-generator/src/pipeline.ts | 22 +- packages/client-generator/src/plugin.ts | 2 + .../ejected-generator/generators/AGENTS.md | 7 +- 13 files changed, 442 insertions(+), 4 deletions(-) create mode 100644 packages/client-generator/src/generators/contract.ts create mode 100644 packages/client-generator/src/intermediate-representation/__tests__/__snapshots__/contract-shape.test.ts.snap create mode 100644 packages/client-generator/src/intermediate-representation/__tests__/contract-shape.test.ts diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index c2efcbd129..63c7cb6f0b 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -76,6 +76,9 @@ For anything else derived from the same description (validators in another libra A generator adds artifacts _next to_ the client — it doesn't change the generated client's behavior; for that, use [publisher defaults](#publisher-defaults) or let the consumer compose [middleware](./use-generated-client.md#middleware). A generator is `{ name, run }` (plus optional compatibility metadata); author it with `defineGenerator` from the package root. +Emitted file paths must stay inside the `--output` directory — subdirectories are fine, escapes are rejected. +A generator may declare `contract` (the `GENERATOR_CONTRACT` number exported by `@redocly/client-generator`); when a future CLI changes the model shape incompatibly, the mismatch then fails upfront with the fix path instead of producing wrong output. +Ejected generators declare it automatically. The output is text, so a generator can emit **any language** — Python models, a Go client, a permissions matrix — not just TypeScript. ### Language-neutral helpers diff --git a/packages/client-generator/eject-assets/AGENTS.md b/packages/client-generator/eject-assets/AGENTS.md index 37843284aa..b0c8ee6c3b 100644 --- a/packages/client-generator/eject-assets/AGENTS.md +++ b/packages/client-generator/eject-assets/AGENTS.md @@ -28,7 +28,12 @@ export default { Rules: output is deterministic (same description → same bytes); never add dependencies to the generated client; **never hand-edit generated output** — -edit this generator and regenerate. +edit this generator and regenerate. Emitted file paths must stay inside the +`--output` directory (subdirectories are fine) — escapes are rejected. +Optionally declare `contract` (the `GENERATOR_CONTRACT` number exported by +`@redocly/client-generator`): a CLI whose contract differs then fails with the +fix path instead of feeding your generator an unexpected model shape. Ejected +generators carry it automatically. ## The model (IR) diff --git a/packages/client-generator/scripts/generate-eject-assets.mjs b/packages/client-generator/scripts/generate-eject-assets.mjs index 1bb4805e74..6d5eb4eaa4 100644 --- a/packages/client-generator/scripts/generate-eject-assets.mjs +++ b/packages/client-generator/scripts/generate-eject-assets.mjs @@ -13,6 +13,15 @@ import { ejectedSkill } from './ejected-skill.mjs'; // these into the user's repo verbatim. const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); const { version } = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8')); +// The contract number lives in ONE place (src/generators/contract.ts); this script +// runs at prepare time (before tsc), so it reads the constant out of the source. +const contractSource = readFileSync(join(pkgRoot, 'src', 'generators', 'contract.ts'), 'utf-8'); +const contractMatch = contractSource.match(/GENERATOR_CONTRACT = (\d+)/); +if (contractMatch === null) { + process.stderr.write('Could not read GENERATOR_CONTRACT from src/generators/contract.ts\n'); + process.exit(1); +} +const contract = Number(contractMatch[1]); const outDir = join(pkgRoot, 'eject-assets', 'generators'); mkdirSync(outDir, { recursive: true }); @@ -43,7 +52,7 @@ for (const { name, run, sample } of EJECTABLE) { '// `redocly eject-generator ' + name + ' --update`.', '', ].join('\n'); - const footer = `\nexport default {\n name: '${name}',\n run: ${run},\n sample: ${sample},\n};\n`; + const footer = `\nexport default {\n name: '${name}',\n run: ${run},\n sample: ${sample},\n contract: ${contract},\n};\n`; const outFile = join(outDir, `${name}.mjs`); writeFileSync(outFile, header + stripped + footer); const check = spawnSync(process.execPath, ['--check', outFile], { encoding: 'utf-8' }); diff --git a/packages/client-generator/src/__tests__/index.test.ts b/packages/client-generator/src/__tests__/index.test.ts index 1ab6e8dd65..5fd8b9412d 100644 --- a/packages/client-generator/src/__tests__/index.test.ts +++ b/packages/client-generator/src/__tests__/index.test.ts @@ -107,6 +107,53 @@ describe('collectGeneratedFiles', () => { ).toThrow(/already emitted/); }); + it('rejects a generated file path that escapes the output directory', () => { + const escapes = [ + { name: 'traversal', path: '../../outside.txt' }, + { name: 'absolute', path: '/etc/outside.txt' }, + ]; + for (const attempt of escapes) { + const registry = new Map([['rogue', { run: () => [{ path: attempt.path, content: 'x' }] }]]); + expect(() => + collectGeneratedFiles(model(), { + outputPath: '/out/api.ts', + outputMode: 'single', + emit: {}, + generators: ['rogue'], + registry, + }) + ).toThrow(/Generator "rogue" failed: .*escapes the output directory/); + } + // Subdirectories under the output directory stay legal (mock fixtures, split files). + const registry = new Map([ + ['nested', { run: () => [{ path: '/out/fixtures/data.json', content: '{}' }] }], + ]); + expect( + collectGeneratedFiles(model(), { + outputPath: '/out/api.ts', + outputMode: 'single', + emit: {}, + generators: ['nested'], + registry, + }) + ).toHaveLength(1); + }); + + it('rejects a run() result that is not an array of { path, content } files', () => { + for (const bad of [undefined, 'files', [{ path: '', content: 'x' }], [{ path: '/out/a' }]]) { + const registry = new Map([['broken', { run: () => bad as never }]]); + expect(() => + collectGeneratedFiles(model(), { + outputPath: '/out/api.ts', + outputMode: 'single', + emit: {}, + generators: ['broken'], + registry, + }) + ).toThrow(/Generator "broken" failed: run\(\) must return/); + } + }); + it('supports runtime: package with outputMode: split (the shared emitter serves both)', () => { const files = collectGeneratedFiles(model(), { outputPath: '/out/api.ts', diff --git a/packages/client-generator/src/generators/__tests__/resolve.test.ts b/packages/client-generator/src/generators/__tests__/resolve.test.ts index b798958f18..46892d3355 100644 --- a/packages/client-generator/src/generators/__tests__/resolve.test.ts +++ b/packages/client-generator/src/generators/__tests__/resolve.test.ts @@ -1,6 +1,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { GENERATOR_CONTRACT } from '../contract.js'; import { resolveGenerators } from '../resolve.js'; import type { CustomGenerator } from '../types.js'; @@ -25,6 +26,26 @@ describe('resolveGenerators', () => { expect(registry.get('route-map')?.run).toBe(noopRun); }); + it('accepts a generator declaring the current contract; rejects any other with the fix path', async () => { + const current: CustomGenerator = { name: 'ok', run: noopRun, contract: GENERATOR_CONTRACT }; + await expect(resolveGenerators(['ok'], { customGenerators: [current] })).resolves.toBeTruthy(); + + const stale: CustomGenerator = { name: 'old', run: noopRun, contract: GENERATOR_CONTRACT - 1 }; + await expect(resolveGenerators(['old'], { customGenerators: [stale] })).rejects.toThrow( + /declares generator contract \d+.*provides \d+.*eject-generator/s + ); + + const future: CustomGenerator = { name: 'new', run: noopRun, contract: GENERATOR_CONTRACT + 1 }; + await expect(resolveGenerators(['new'], { customGenerators: [future] })).rejects.toThrow( + /Update @redocly\/cli/ + ); + // No declaration keeps friction-free authoring — accepted as current. + const undeclared: CustomGenerator = { name: 'bare', run: noopRun }; + await expect( + resolveGenerators(['bare'], { customGenerators: [undeclared] }) + ).resolves.toBeTruthy(); + }); + it('registers an inline custom that is available (for requires) but not selected', async () => { const custom: CustomGenerator = { name: 'extra', run: noopRun }; const { selected, registry } = await resolveGenerators(['sdk'], { customGenerators: [custom] }); diff --git a/packages/client-generator/src/generators/contract.ts b/packages/client-generator/src/generators/contract.ts new file mode 100644 index 0000000000..f8ebb7515a --- /dev/null +++ b/packages/client-generator/src/generators/contract.ts @@ -0,0 +1,12 @@ +/** + * The custom-generator contract version: the shape of the IR (`ApiModel`), the + * `GeneratorInput`, and the authoring helpers a generator is written against. + * + * Bump ONLY on a breaking change to any of those (removing/renaming a field, + * changing semantics) — additive changes keep the number. A generator that + * declares a different contract is rejected at resolve time with the fix path, + * so a breaking change surfaces as one clear message instead of silently wrong + * output. Ejected generators are stamped with the current value at prepare time + * (see scripts/generate-eject-assets.mjs, which reads this file). + */ +export const GENERATOR_CONTRACT = 1; diff --git a/packages/client-generator/src/generators/resolve.ts b/packages/client-generator/src/generators/resolve.ts index cebbecb98b..22cec0b9bd 100644 --- a/packages/client-generator/src/generators/resolve.ts +++ b/packages/client-generator/src/generators/resolve.ts @@ -10,6 +10,7 @@ import { isAbsolute, resolve as resolvePath } from 'node:path'; import { pathToFileURL } from 'node:url'; import { NotSupportedError } from '../errors.js'; +import { GENERATOR_CONTRACT } from './contract.js'; import { BUILTIN_META, type BuiltinMeta } from './meta.js'; import type { CustomGenerator, GeneratorDescriptor } from './types.js'; @@ -77,6 +78,16 @@ function register(registry: Map, custom: CustomGene `Generator name "${custom.name}" collides with an existing generator. Rename the custom generator.` ); } + // A declared contract must match exactly — the number only moves on breaking + // changes, so any difference means the generator and this CLI disagree on the IR. + if (custom.contract !== undefined && custom.contract !== GENERATOR_CONTRACT) { + throw new NotSupportedError( + `Generator "${custom.name}" declares generator contract ${custom.contract}; this CLI provides ${GENERATOR_CONTRACT}. ` + + (custom.contract > GENERATOR_CONTRACT + ? 'Update @redocly/cli.' + : 'Update the generator — `redocly eject-generator --update` for ejected files, or upgrade the package.') + ); + } // A custom generator MAY take over a built-in name — that's how an ejected // generator replaces its origin without a config rename. Announce the takeover. if (custom.name in BUILTIN_META) { diff --git a/packages/client-generator/src/generators/types.ts b/packages/client-generator/src/generators/types.ts index ce7de44694..62935a8186 100644 --- a/packages/client-generator/src/generators/types.ts +++ b/packages/client-generator/src/generators/types.ts @@ -93,4 +93,11 @@ export type GeneratorDescriptor = { export type CustomGenerator = GeneratorDescriptor & { /** Unique name, used in `generators` selection, `requires`, and collision detection. */ name: string; + /** + * The generator contract this module was written against (see `GENERATOR_CONTRACT`). + * A declared mismatch is rejected at resolve time with the fix path; omitting it + * accepts the generator as current (friction-free hand authoring). Ejected + * generators carry it automatically. + */ + contract?: number; }; diff --git a/packages/client-generator/src/intermediate-representation/__tests__/__snapshots__/contract-shape.test.ts.snap b/packages/client-generator/src/intermediate-representation/__tests__/__snapshots__/contract-shape.test.ts.snap new file mode 100644 index 0000000000..951288f1bc --- /dev/null +++ b/packages/client-generator/src/intermediate-representation/__tests__/__snapshots__/contract-shape.test.ts.snap @@ -0,0 +1,201 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`IR contract shape > pins the full ApiModel a generator receives for a representative document 1`] = ` +{ + "schemas": [ + { + "name": "Order", + "schema": { + "kind": "object", + "properties": [ + { + "name": "id", + "readOnly": true, + "required": true, + "schema": { + "kind": "scalar", + "scalar": "string", + }, + }, + { + "name": "status", + "required": false, + "schema": { + "kind": "ref", + "name": "Status", + }, + }, + ], + }, + }, + { + "name": "Status", + "schema": { + "kind": "enum", + "scalar": "string", + "values": [ + "open", + "shipped", + ], + }, + }, + { + "name": "Pet", + "schema": { + "discriminator": { + "mapping": [ + { + "schemaName": "Order", + "value": "order", + }, + ], + "propertyName": "kind", + }, + "kind": "union", + "members": [ + { + "kind": "ref", + "name": "Order", + }, + ], + }, + }, + ], + "securitySchemes": [ + { + "key": "BearerAuth", + "kind": "bearer", + }, + ], + "serverUrl": "https://api.example.com/us", + "servers": [ + { + "description": "Live server", + "url": "https://api.example.com/{region}", + "variables": [ + { + "default": "us", + "name": "region", + }, + ], + }, + ], + "services": [ + { + "name": "Default", + "operations": [ + { + "cookieParams": [], + "errorResponses": [ + { + "contentType": "application/json", + "schema": { + "kind": "record", + "value": { + "kind": "unknown", + }, + }, + "status": 404, + }, + ], + "headerParams": [ + { + "in": "header", + "name": "X-Trace", + "required": false, + "schema": { + "kind": "scalar", + "scalar": "string", + }, + }, + ], + "method": "get", + "name": "listOrders", + "path": "/orders", + "pathParams": [], + "queryParams": [ + { + "in": "query", + "name": "limit", + "required": false, + "schema": { + "kind": "scalar", + "metadata": { + "minimum": 1, + }, + "scalar": "integer", + }, + }, + ], + "security": [ + [ + "BearerAuth", + ], + ], + "successResponseHeaders": [ + { + "name": "pagination-total", + "required": true, + "schema": { + "kind": "scalar", + "scalar": "integer", + }, + }, + ], + "successResponses": [ + { + "contentType": "application/json", + "schema": { + "items": { + "kind": "ref", + "name": "Order", + }, + "kind": "array", + }, + "status": 200, + }, + ], + "tags": [ + "Orders", + ], + }, + { + "cookieParams": [], + "errorResponses": [], + "headerParams": [], + "method": "post", + "name": "createOrder", + "path": "/orders", + "pathParams": [], + "queryParams": [], + "requestBody": { + "contentType": "application/json", + "required": true, + "schema": { + "base": "Order", + "keys": [ + "id", + ], + "kind": "omit", + }, + }, + "security": [], + "successResponses": [ + { + "contentType": "application/json", + "schema": { + "kind": "ref", + "name": "Order", + }, + "status": 201, + }, + ], + "tags": [], + }, + ], + }, + ], + "title": "Contract Probe", + "version": "1.0.0", +} +`; diff --git a/packages/client-generator/src/intermediate-representation/__tests__/contract-shape.test.ts b/packages/client-generator/src/intermediate-representation/__tests__/contract-shape.test.ts new file mode 100644 index 0000000000..a34b83024f --- /dev/null +++ b/packages/client-generator/src/intermediate-representation/__tests__/contract-shape.test.ts @@ -0,0 +1,95 @@ +// The IR is the custom-generator contract: every field below is public API that +// ejected and custom generators read. If this snapshot changes, decide whether the +// change is ADDITIVE (update the snapshot, contract number stays) or BREAKING +// (removed/renamed field, changed semantics — bump GENERATOR_CONTRACT in +// generators/contract.ts so mismatched generators fail with the fix path instead +// of misbehaving). + +import type { Oas3Definition } from '@redocly/openapi-core'; + +import { buildApiModel } from '../build.js'; + +const DOC = { + openapi: '3.1.0', + info: { title: 'Contract Probe', version: '1.0.0' }, + servers: [ + { + url: 'https://api.example.com/{region}', + description: 'Live server', + variables: { region: { default: 'us' } }, + }, + ], + paths: { + '/orders': { + get: { + operationId: 'listOrders', + tags: ['Orders'], + parameters: [ + { name: 'limit', in: 'query', schema: { type: 'integer', minimum: 1 } }, + { name: 'X-Trace', in: 'header', schema: { type: 'string' } }, + ], + responses: { + '200': { + description: 'ok', + headers: { + 'Pagination-Total': { schema: { type: 'integer' }, required: true }, + }, + content: { + 'application/json': { + schema: { type: 'array', items: { $ref: '#/components/schemas/Order' } }, + }, + }, + }, + '404': { + description: 'missing', + content: { 'application/json': { schema: { type: 'object' } } }, + }, + }, + security: [{ BearerAuth: [] }], + }, + post: { + operationId: 'createOrder', + requestBody: { + required: true, + content: { + 'application/json': { schema: { $ref: '#/components/schemas/Order' } }, + }, + }, + responses: { + '201': { + description: 'created', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/Order' } }, + }, + }, + }, + }, + }, + }, + components: { + schemas: { + Order: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string', readOnly: true }, + status: { $ref: '#/components/schemas/Status' }, + }, + }, + Status: { type: 'string', enum: ['open', 'shipped'] }, + Pet: { + oneOf: [{ $ref: '#/components/schemas/Order' }], + discriminator: { propertyName: 'kind', mapping: { order: '#/components/schemas/Order' } }, + }, + }, + securitySchemes: { + BearerAuth: { type: 'http', scheme: 'bearer' }, + }, + }, +} as unknown as Oas3Definition; + +describe('IR contract shape', () => { + it('pins the full ApiModel a generator receives for a representative document', () => { + expect(JSON.parse(JSON.stringify(buildApiModel(DOC)))).toMatchSnapshot(); + }); +}); diff --git a/packages/client-generator/src/pipeline.ts b/packages/client-generator/src/pipeline.ts index 656802e2e6..9185edc8da 100644 --- a/packages/client-generator/src/pipeline.ts +++ b/packages/client-generator/src/pipeline.ts @@ -8,7 +8,7 @@ import { stringifyYaml } from '@redocly/openapi-core'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { dirname, resolve } from 'node:path'; +import { dirname, resolve, sep } from 'node:path'; import type { EmitOptions } from './emitters/emit-options.js'; import { NotSupportedError } from './errors.js'; @@ -43,6 +43,9 @@ export function runGenerators( ): GeneratedFile[] { const files: GeneratedFile[] = []; const seen = new Set(); + // Every emitted path must stay under the --output directory: generator modules are + // user-chosen code, but a stray `../` or absolute path must not write elsewhere. + const outputRoot = resolve(dirname(options.outputPath)); for (const name of options.generators) { const generator = options.registry.get(name)!; let generated: GeneratedFile[]; @@ -58,7 +61,24 @@ export function runGenerators( const message = error instanceof Error ? error.message : String(error); throw new Error(`Generator "${name}" failed: ${message}`); } + if ( + !Array.isArray(generated) || + generated.some( + (file) => + typeof file?.path !== 'string' || file.path === '' || typeof file.content !== 'string' + ) + ) { + throw new Error( + `Generator "${name}" failed: run() must return an array of { path, content } files.` + ); + } for (const file of generated) { + const resolved = resolve(outputRoot, file.path); + if (resolved !== outputRoot && !resolved.startsWith(outputRoot + sep)) { + throw new Error( + `Generator "${name}" failed: file path escapes the output directory: ${file.path}` + ); + } if (seen.has(file.path)) { throw new Error(`Generator conflict: ${file.path} already emitted by an earlier generator`); } diff --git a/packages/client-generator/src/plugin.ts b/packages/client-generator/src/plugin.ts index 28b247cf73..f003863598 100644 --- a/packages/client-generator/src/plugin.ts +++ b/packages/client-generator/src/plugin.ts @@ -28,6 +28,8 @@ import type { CustomGenerator } from './generators/types.js'; +export { GENERATOR_CONTRACT } from './generators/contract.js'; + /** * Identity helper for authoring a custom generator with full type inference and one validation * choke-point. `export default defineGenerator({ name, run, … })`. Returns its argument unchanged. diff --git a/tests/e2e/generate-client/examples/ejected-generator/generators/AGENTS.md b/tests/e2e/generate-client/examples/ejected-generator/generators/AGENTS.md index bea3e74dd1..a498db410b 100644 --- a/tests/e2e/generate-client/examples/ejected-generator/generators/AGENTS.md +++ b/tests/e2e/generate-client/examples/ejected-generator/generators/AGENTS.md @@ -30,7 +30,12 @@ export default { Rules: output is deterministic (same description → same bytes); never add dependencies to the generated client; **never hand-edit generated output** — -edit this generator and regenerate. +edit this generator and regenerate. Emitted file paths must stay inside the +`--output` directory (subdirectories are fine) — escapes are rejected. +Optionally declare `contract` (the `GENERATOR_CONTRACT` number exported by +`@redocly/client-generator`): a CLI whose contract differs then fails with the +fix path instead of feeding your generator an unexpected model shape. Ejected +generators carry it automatically. ## The model (IR) From ea9d83454027d08d8a444d12c8f0d095a62a6ae5 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 4 Aug 2026 17:44:50 +0300 Subject: [PATCH 075/211] feat: response-header envelope parity for php, python, and go via WithHeaders variants --- .../client-generator/eject-assets/AGENTS.md | 1 + .../client-generator/go-runtime/runtime.go | 45 ++++++++++ .../client-generator/php-runtime/runtime.php | 36 ++++++++ .../client-generator/python-runtime/_send.py | 38 +++++++- .../client-generator/src/authoring/index.ts | 2 + .../client-generator/src/authoring/schema.ts | 55 +++++++++++- .../src/emitters/go-runtime-sources.ts | 2 +- .../src/emitters/php-runtime-sources.ts | 2 +- .../src/emitters/python-runtime-sources.ts | 2 +- .../src/emitters/response-headers.ts | 45 ++-------- .../src/generators/__tests__/go.test.ts | 23 +++++ .../src/generators/__tests__/php.test.ts | 20 +++++ .../src/generators/__tests__/python.test.ts | 21 +++++ .../src/generators/go/AGENTS.md | 5 ++ .../src/generators/go/index.ts | 90 +++++++++++++++++-- .../src/generators/php/AGENTS.md | 10 ++- .../src/generators/php/index.ts | 58 ++++++++++-- .../src/generators/python/AGENTS.md | 5 ++ .../src/generators/python/index.ts | 49 ++++++++-- .../ejected-generator/generators/AGENTS.md | 1 + .../generators/php.AGENTS.md | 10 ++- 21 files changed, 451 insertions(+), 69 deletions(-) diff --git a/packages/client-generator/eject-assets/AGENTS.md b/packages/client-generator/eject-assets/AGENTS.md index b0c8ee6c3b..d2c385ac1e 100644 --- a/packages/client-generator/eject-assets/AGENTS.md +++ b/packages/client-generator/eject-assets/AGENTS.md @@ -53,6 +53,7 @@ discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, | `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). | | `Printer` | Indentation-aware text builder — no manual whitespace bookkeeping. | | `docText(description)` | Description as trimmed lines for any comment syntax. | diff --git a/packages/client-generator/go-runtime/runtime.go b/packages/client-generator/go-runtime/runtime.go index a086a8f9ab..5f43623f4d 100644 --- a/packages/client-generator/go-runtime/runtime.go +++ b/packages/client-generator/go-runtime/runtime.go @@ -375,6 +375,51 @@ func decodeJSON(resp *http.Response, target any) error { return json.NewDecoder(resp.Body).Decode(target) } +// headerString returns the named response header, or nil when absent. +func headerString(header http.Header, name string) *string { + value := header.Get(name) + if value == "" { + return nil + } + return &value +} + +// headerInt64 parses the named header as an integer; nil when absent or unparsable. +func headerInt64(header http.Header, name string) *int64 { + raw := strings.TrimSpace(header.Get(name)) + if raw == "" { + return nil + } + value, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return nil + } + return &value +} + +// headerFloat64 parses the named header as a number; nil when absent or unparsable. +func headerFloat64(header http.Header, name string) *float64 { + raw := strings.TrimSpace(header.Get(name)) + if raw == "" { + return nil + } + value, err := strconv.ParseFloat(raw, 64) + if err != nil { + return nil + } + return &value +} + +// headerBool parses a `true`/`false` header; nil when absent or anything else. +func headerBool(header http.Header, name string) *bool { + raw := strings.ToLower(strings.TrimSpace(header.Get(name))) + if raw != "true" && raw != "false" { + return nil + } + value := raw == "true" + return &value +} + // apiErrorFrom builds the structured error for a non-2xx response. func apiErrorFrom(resp *http.Response, requestURL string) error { defer resp.Body.Close() diff --git a/packages/client-generator/php-runtime/runtime.php b/packages/client-generator/php-runtime/runtime.php index 1f2f704f76..72b6c612ce 100644 --- a/packages/client-generator/php-runtime/runtime.php +++ b/packages/client-generator/php-runtime/runtime.php @@ -35,6 +35,42 @@ public function __construct( } /** One parsed `text/event-stream` frame. */ +/** A `WithHeaders()` result: the decoded body plus coerced declared headers. */ +final class Envelope +{ + public function __construct( + public readonly mixed $data, + public readonly array $headers, + public readonly int $status, + ) { + } +} + +/** Coerce declared response headers per `[name, key, type]` specs; absent/unparsable omitted. */ +function readEnvelopeHeaders(array $response, array $specs): array +{ + $headers = []; + foreach ($specs as [$name, $key, $type]) { + $raw = $response['headers'][$name] ?? null; + if ($raw === null) { + continue; + } + if ($type === 'integer' || $type === 'number') { + if (is_numeric($raw)) { + $headers[$key] = $type === 'integer' ? (int) $raw : (float) $raw; + } + } elseif ($type === 'boolean') { + $lower = strtolower(trim($raw)); + if ($lower === 'true' || $lower === 'false') { + $headers[$key] = $lower === 'true'; + } + } else { + $headers[$key] = $raw; + } + } + return $headers; +} + final class ServerSentEvent { public function __construct( diff --git a/packages/client-generator/python-runtime/_send.py b/packages/client-generator/python-runtime/_send.py index a46eba6921..ffabcbbccf 100644 --- a/packages/client-generator/python-runtime/_send.py +++ b/packages/client-generator/python-runtime/_send.py @@ -10,12 +10,48 @@ import random import time import uuid -from typing import Any, Dict, List, Optional +from dataclasses import dataclass +from typing import Any, Dict, Generic, List, Optional, Tuple, TypeVar import httpx from ._errors import ApiTimeoutError +T = TypeVar("T") + + +@dataclass +class Envelope(Generic[T]): + """A *_with_headers() result: decoded body + coerced declared headers + raw response.""" + + data: T + headers: Dict[str, Any] + response: httpx.Response + + +def read_envelope_headers( + response: httpx.Response, specs: List[Tuple[str, str, str]] +) -> Dict[str, Any]: + """Coerce declared response headers per (name, key, type) specs; absent/unparsable omitted.""" + headers: Dict[str, Any] = {} + for name, key, type_ in specs: + raw = response.headers.get(name) + if raw is None: + continue + if type_ in ("integer", "number"): + try: + headers[key] = int(raw) if type_ == "integer" else float(raw) + except ValueError: + pass + elif type_ == "boolean": + lower = raw.strip().lower() + if lower in ("true", "false"): + headers[key] = lower == "true" + else: + headers[key] = raw + return headers + + _IDEMPOTENT_METHODS = {"GET", "HEAD", "PUT", "DELETE", "OPTIONS"} _TRANSIENT_STATUS = {408, 429, 500, 502, 503, 504} diff --git a/packages/client-generator/src/authoring/index.ts b/packages/client-generator/src/authoring/index.ts index 7eb6d18f88..ebb711c979 100644 --- a/packages/client-generator/src/authoring/index.ts +++ b/packages/client-generator/src/authoring/index.ts @@ -10,6 +10,7 @@ export { docText, enumValues, flattenAllOf, + headerCoerceType, isNullable, schemaAtPointer, unwrapNullable, @@ -27,6 +28,7 @@ export const AUTHORING_HELPER_NAMES = [ 'unwrapNullable', 'enumValues', 'docText', + 'headerCoerceType', 'schemaAtPointer', 'paginationRuleFor', ] as const; diff --git a/packages/client-generator/src/authoring/schema.ts b/packages/client-generator/src/authoring/schema.ts index 4de86c9970..4dbe4b436c 100644 --- a/packages/client-generator/src/authoring/schema.ts +++ b/packages/client-generator/src/authoring/schema.ts @@ -2,7 +2,12 @@ // discriminators, nullability, enums) exposed as pure functions over the IR, so // a generator in ANY output language never re-implements schema semantics. -import type { ApiModel, PropertyModel, SchemaModel } from '../intermediate-representation/model.js'; +import type { + ApiModel, + NamedSchemaModel, + PropertyModel, + SchemaModel, +} from '../intermediate-representation/model.js'; import { casing } from './naming.js'; /** Follow a `ref` chain through the model's named schemas; undefined on a miss or cycle. */ @@ -139,3 +144,51 @@ export function schemaAtPointer( } return current; } + +/** + * The wire-coerce hint for a response HEADER schema: `'integer'` / `'number'` / + * `'boolean'` for scalar-ish leaves, `'string'` for everything else (headers are + * strings on the wire; complex schemas have no sensible coercion). Resolves `ref`s + * through the model, peels nullable unions and constraint-only `allOf` members. + */ +export function headerCoerceType( + schema: SchemaModel, + model: { schemas: readonly NamedSchemaModel[] }, + seen: Set = new Set() +): 'string' | 'number' | 'integer' | 'boolean' { + if (schema.kind === 'ref') { + if (seen.has(schema.name)) return 'string'; + seen.add(schema.name); + const named = model.schemas.find((entry) => entry.name === schema.name); + if (named === undefined) return 'string'; + return headerCoerceType(named.schema, model, seen); + } + if (schema.kind === 'intersection') { + const members = schema.members.filter((member) => member.kind !== 'unknown'); + if (members.length === 1) return headerCoerceType(members[0], model, seen); + const types = [ + ...new Set(members.map((member) => headerCoerceType(member, model, new Set(seen)))), + ]; + if (types.length === 1) return types[0]; + // An integer member refined by a number bound (or vice versa) stays numeric. + if (types.every((type) => type === 'integer' || type === 'number')) return 'number'; + return 'string'; + } + if (schema.kind === 'union') { + const members = schema.members.filter((member) => member.kind !== 'null'); + if (members.length === 1) return headerCoerceType(members[0], model, seen); + return 'string'; + } + if (schema.kind === 'scalar' || schema.kind === 'enum') { + if (schema.scalar === 'integer') return 'integer'; + if (schema.scalar === 'number') return 'number'; + if (schema.scalar === 'boolean') return 'boolean'; + } + if (schema.kind === 'literal') { + if (typeof schema.value === 'number') { + return Number.isInteger(schema.value) ? 'integer' : 'number'; + } + if (typeof schema.value === 'boolean') return 'boolean'; + } + return 'string'; +} diff --git a/packages/client-generator/src/emitters/go-runtime-sources.ts b/packages/client-generator/src/emitters/go-runtime-sources.ts index 4e0d2a9a36..fcd1b488de 100644 --- a/packages/client-generator/src/emitters/go-runtime-sources.ts +++ b/packages/client-generator/src/emitters/go-runtime-sources.ts @@ -1,3 +1,3 @@ // GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`. export const GO_RUNTIME_SOURCE = - '// Package client — the embedded runtime for generated Go SDKs. Hand-authored\n// once and stitched into every generated client (see\n// scripts/generate-runtime-sources.mjs), semantically in lockstep with the\n// TypeScript runtime: auth OR-alternatives, a retry loop with Retry-After and\n// full-jitter backoff, per-attempt timeouts, idempotency keys, and middleware\n// hooks. Standard library only — a generated Go SDK has zero dependencies.\npackage client\n\nimport (\n\t"bytes"\n\t"context"\n\t"encoding/base64"\n\t"encoding/json"\n\t"errors"\n\t"fmt"\n\t"io"\n\t"math/rand"\n\t"mime/multipart"\n\t"net/http"\n\t"net/url"\n\t"strconv"\n\t"strings"\n\t"time"\n)\n\n// APIError is returned for a non-2xx response, carrying the decoded error body.\ntype APIError struct {\n\tURL string\n\tStatus int\n\tStatusText string\n\tBody any\n}\n\nfunc (e *APIError) Error() string {\n\treturn fmt.Sprintf("request failed with status %d", e.Status)\n}\n\n// TimeoutError is returned when a request attempt exceeds the configured\n// timeout — carrying the context a log line needs.\ntype TimeoutError struct {\n\tOperationID string\n\tTimeout time.Duration\n\tAttempt int\n}\n\nfunc (e *TimeoutError) Error() string {\n\treturn fmt.Sprintf("request %q timed out after %s (attempt %d)", e.OperationID, e.Timeout, e.Attempt)\n}\n\n// SecuritySpec mirrors the descriptor table\'s security entries.\ntype SecuritySpec struct {\n\tScheme string\n\tKind string // "bearer" | "basic" | "apiKey"\n\tName string // header/query/cookie name for apiKey\n\tIn string // "header" | "query" | "cookie"\n}\n\n// Auth holds the client credentials; zero value = anonymous.\ntype Auth struct {\n\tBearer func() string\n\tBasic *BasicAuth\n\tAPIKey map[string]func() string\n}\n\ntype BasicAuth struct {\n\tUsername string\n\tPassword string\n}\n\n// RetryConfig mirrors the TypeScript runtime\'s retry policy knobs.\ntype RetryConfig struct {\n\tRetries int\n\tRetryDelay time.Duration // base; default 1s\n\tRetryStrategy string // "" (exponential) | "fixed"\n\tNoJitter bool\n\t// RetryOn fully replaces the default predicate when set.\n\tRetryOn func(attempt int, resp *http.Response, err error) bool\n}\n\n// Middleware hooks run around every request (OnRequest before serialization order\n// is N/A in Go — bodies are values; OnResponse runs in reverse registration order).\ntype Middleware struct {\n\tOnRequest func(req *http.Request)\n\tOnResponse func(resp *http.Response)\n}\n\n// Config is the per-client configuration shared by every operation method.\ntype Config struct {\n\tServerURL string\n\tHTTPClient *http.Client\n\tHeaders map[string]string\n\tTimeout time.Duration\n\tRetry RetryConfig\n\tMiddleware []Middleware\n\tIdempotencyKey func() string\n\tAuth Auth\n}\n\nfunc resolveToken(provider func() string) string {\n\tif provider == nil {\n\t\treturn ""\n\t}\n\treturn provider()\n}\n\nfunc schemeConfigured(spec SecuritySpec, auth Auth) bool {\n\tswitch spec.Kind {\n\tcase "apiKey":\n\t\t_, ok := auth.APIKey[spec.Scheme]\n\t\treturn ok\n\tcase "bearer":\n\t\treturn auth.Bearer != nil\n\tdefault:\n\t\treturn auth.Basic != nil\n\t}\n}\n\n// resolveAuth applies the first fully-configured OR-alternative; when none is,\n// the first alternative\'s configured schemes are still sent (the server rejects\n// the request — same behavior as the TypeScript runtime).\nfunc resolveAuth(security [][]SecuritySpec, auth Auth) (map[string]string, url.Values) {\n\theaders := map[string]string{}\n\tquery := url.Values{}\n\tif len(security) == 0 {\n\t\treturn headers, query\n\t}\n\talternative := security[0]\n\tfor _, candidate := range security {\n\t\tall := true\n\t\tfor _, spec := range candidate {\n\t\t\tif !schemeConfigured(spec, auth) {\n\t\t\t\tall = false\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif all {\n\t\t\talternative = candidate\n\t\t\tbreak\n\t\t}\n\t}\n\tvar cookies []string\n\tfor _, spec := range alternative {\n\t\tswitch spec.Kind {\n\t\tcase "apiKey":\n\t\t\tprovider, ok := auth.APIKey[spec.Scheme]\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalue := resolveToken(provider)\n\t\t\tswitch spec.In {\n\t\t\tcase "query":\n\t\t\t\tquery.Set(spec.Name, value)\n\t\t\tcase "cookie":\n\t\t\t\tcookies = append(cookies, spec.Name+"="+url.QueryEscape(value))\n\t\t\tdefault:\n\t\t\t\theaders[spec.Name] = value\n\t\t\t}\n\t\tcase "bearer":\n\t\t\tif auth.Bearer != nil {\n\t\t\t\theaders["Authorization"] = "Bearer " + resolveToken(auth.Bearer)\n\t\t\t}\n\t\tdefault:\n\t\t\tif auth.Basic != nil {\n\t\t\t\ttoken := base64.StdEncoding.EncodeToString([]byte(auth.Basic.Username + ":" + auth.Basic.Password))\n\t\t\t\theaders["Authorization"] = "Basic " + token\n\t\t\t}\n\t\t}\n\t}\n\tif len(cookies) > 0 {\n\t\theaders["Cookie"] = strings.Join(cookies, "; ")\n\t}\n\treturn headers, query\n}\n\n// buildURL substitutes {param} path placeholders with percent-encoded values.\nfunc buildURL(serverURL, path string, pathParams map[string]string) string {\n\tfilled := path\n\tfor name, value := range pathParams {\n\t\tfilled = strings.ReplaceAll(filled, "{"+name+"}", url.PathEscape(value))\n\t}\n\treturn strings.TrimRight(serverURL, "/") + filled\n}\n\nvar transientStatus = map[int]bool{408: true, 429: true, 500: true, 502: true, 503: true, 504: true}\n\nfunc defaultRetryOn(method string, headers map[string]string, resp *http.Response, err error) bool {\n\tsafe := false\n\tswitch strings.ToUpper(method) {\n\tcase "GET", "HEAD", "PUT", "DELETE", "OPTIONS":\n\t\tsafe = true\n\t}\n\tif _, ok := headers["Idempotency-Key"]; ok {\n\t\tsafe = true\n\t}\n\tif !safe {\n\t\treturn false\n\t}\n\tif err != nil {\n\t\treturn true\n\t}\n\treturn resp != nil && transientStatus[resp.StatusCode]\n}\n\nfunc retryDelay(retry RetryConfig, attempt int, retryAfter string) time.Duration {\n\tif retryAfter != "" {\n\t\tif seconds, err := strconv.ParseFloat(retryAfter, 64); err == nil {\n\t\t\treturn time.Duration(seconds * float64(time.Second))\n\t\t}\n\t}\n\tbase := retry.RetryDelay\n\tif base == 0 {\n\t\tbase = time.Second\n\t}\n\traw := base\n\tif retry.RetryStrategy != "fixed" {\n\t\traw = base * time.Duration(1<<(attempt-1))\n\t}\n\tif retry.NoJitter {\n\t\treturn raw\n\t}\n\treturn time.Duration(rand.Int63n(int64(raw) + 1))\n}\n\ntype requestSpec struct {\n\tOperationID string\n\tMethod string\n\tURL string\n\tHeaders map[string]string\n\tQuery url.Values\n\tBody io.Reader\n\tContentType string\n\tTimeout time.Duration\n\tRetry *RetryConfig\n\tIdempotencyKey string\n\t// bodyBytes is retained so retries can replay the body.\n\tbodyBytes []byte\n}\n\n// send is the request core: header merge, idempotency keys, the retry loop\n// (fresh timeout budget per attempt), and the middleware onion.\nfunc send(ctx context.Context, config *Config, spec requestSpec) (*http.Response, error) {\n\tretry := config.Retry\n\tif spec.Retry != nil {\n\t\tretry = *spec.Retry\n\t}\n\ttimeout := config.Timeout\n\tif spec.Timeout != 0 {\n\t\ttimeout = spec.Timeout\n\t}\n\theaders := map[string]string{}\n\tfor key, value := range config.Headers {\n\t\theaders[key] = value\n\t}\n\tfor key, value := range spec.Headers {\n\t\theaders[key] = value\n\t}\n\tmethod := strings.ToUpper(spec.Method)\n\tif (method == "POST" || method == "PATCH") && headers["Idempotency-Key"] == "" {\n\t\tif spec.IdempotencyKey != "" {\n\t\t\theaders["Idempotency-Key"] = spec.IdempotencyKey\n\t\t} else if config.IdempotencyKey != nil {\n\t\t\theaders["Idempotency-Key"] = config.IdempotencyKey()\n\t\t}\n\t}\n\thttpClient := config.HTTPClient\n\tif httpClient == nil {\n\t\thttpClient = http.DefaultClient\n\t}\n\tif spec.Body != nil {\n\t\tpayload, err := io.ReadAll(spec.Body)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tspec.bodyBytes = payload\n\t}\n\tfullURL := spec.URL\n\tif len(spec.Query) > 0 {\n\t\tseparator := "?"\n\t\tif strings.Contains(fullURL, "?") {\n\t\t\tseparator = "&"\n\t\t}\n\t\tfullURL += separator + spec.Query.Encode()\n\t}\n\tmaxAttempts := 1 + retry.Retries\n\tfor attempt := 1; ; attempt++ {\n\t\tattemptCtx := ctx\n\t\tvar cancel context.CancelFunc\n\t\tif timeout > 0 {\n\t\t\tattemptCtx, cancel = context.WithTimeout(ctx, timeout)\n\t\t}\n\t\tvar bodyReader io.Reader\n\t\tif spec.bodyBytes != nil {\n\t\t\tbodyReader = bytes.NewReader(spec.bodyBytes)\n\t\t}\n\t\treq, err := http.NewRequestWithContext(attemptCtx, method, fullURL, bodyReader)\n\t\tif err != nil {\n\t\t\tif cancel != nil {\n\t\t\t\tcancel()\n\t\t\t}\n\t\t\treturn nil, err\n\t\t}\n\t\tfor key, value := range headers {\n\t\t\treq.Header.Set(key, value)\n\t\t}\n\t\tif spec.ContentType != "" && spec.bodyBytes != nil {\n\t\t\treq.Header.Set("Content-Type", spec.ContentType)\n\t\t}\n\t\tfor _, mw := range config.Middleware {\n\t\t\tif mw.OnRequest != nil {\n\t\t\t\tmw.OnRequest(req)\n\t\t\t}\n\t\t}\n\t\tresp, err := httpClient.Do(req)\n\t\tshouldRetry := retry.RetryOn\n\t\tretryable := false\n\t\tif shouldRetry != nil {\n\t\t\tretryable = shouldRetry(attempt, resp, err)\n\t\t} else {\n\t\t\tretryable = defaultRetryOn(method, headers, resp, err)\n\t\t}\n\t\tif err != nil {\n\t\t\tif cancel != nil {\n\t\t\t\tcancel()\n\t\t\t}\n\t\t\ttimedOut := errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil\n\t\t\tif attempt < maxAttempts && retryable {\n\t\t\t\ttime.Sleep(retryDelay(retry, attempt, ""))\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif timedOut {\n\t\t\t\treturn nil, &TimeoutError{OperationID: spec.OperationID, Timeout: timeout, Attempt: attempt}\n\t\t\t}\n\t\t\treturn nil, err\n\t\t}\n\t\tfor i := len(config.Middleware) - 1; i >= 0; i-- {\n\t\t\tif config.Middleware[i].OnResponse != nil {\n\t\t\t\tconfig.Middleware[i].OnResponse(resp)\n\t\t\t}\n\t\t}\n\t\tif resp.StatusCode >= 400 && attempt < maxAttempts && retryable {\n\t\t\tafter := resp.Header.Get("Retry-After")\n\t\t\tio.Copy(io.Discard, resp.Body)\n\t\t\tresp.Body.Close()\n\t\t\tif cancel != nil {\n\t\t\t\tcancel()\n\t\t\t}\n\t\t\ttime.Sleep(retryDelay(retry, attempt, after))\n\t\t\tcontinue\n\t\t}\n\t\t// The response body outlives this call; tie the attempt context\'s lifetime to it.\n\t\tif cancel != nil {\n\t\t\tresp.Body = &cancelOnClose{ReadCloser: resp.Body, cancel: cancel}\n\t\t}\n\t\treturn resp, nil\n\t}\n}\n\ntype cancelOnClose struct {\n\tio.ReadCloser\n\tcancel context.CancelFunc\n}\n\nfunc (c *cancelOnClose) Close() error {\n\tc.cancel()\n\treturn c.ReadCloser.Close()\n}\n\n// decodeJSON decodes a response body into target; a nil target drains and closes.\nfunc decodeJSON(resp *http.Response, target any) error {\n\tdefer resp.Body.Close()\n\tif target == nil {\n\t\t_, err := io.Copy(io.Discard, resp.Body)\n\t\treturn err\n\t}\n\treturn json.NewDecoder(resp.Body).Decode(target)\n}\n\n// apiErrorFrom builds the structured error for a non-2xx response.\nfunc apiErrorFrom(resp *http.Response, requestURL string) error {\n\tdefer resp.Body.Close()\n\tvar body any\n\tdata, _ := io.ReadAll(resp.Body)\n\tif len(data) > 0 {\n\t\tif err := json.Unmarshal(data, &body); err != nil {\n\t\t\tbody = string(data)\n\t\t}\n\t}\n\treturn &APIError{URL: requestURL, Status: resp.StatusCode, StatusText: resp.Status, Body: body}\n}\n\n// ─── Pagination ───\n\n// PaginationSpec mirrors the descriptor table\'s pagination entries.\ntype PaginationSpec struct {\n\tStyle string\n\tParam string\n\tNextCursor string\n\tHasMore string\n\tLimitParam string\n\tItems string\n}\n\n// resolvePointer walks an RFC 6901 JSON pointer over decoded JSON; nil on any miss.\nfunc resolvePointer(data any, pointer string) any {\n\tif pointer == "" {\n\t\treturn data\n\t}\n\tif !strings.HasPrefix(pointer, "/") {\n\t\treturn nil\n\t}\n\tcurrent := data\n\tfor _, token := range strings.Split(pointer[1:], "/") {\n\t\tkey := strings.ReplaceAll(strings.ReplaceAll(token, "~1", "/"), "~0", "~")\n\t\tswitch typed := current.(type) {\n\t\tcase map[string]any:\n\t\t\tcurrent = typed[key]\n\t\tcase []any:\n\t\t\tindex, err := strconv.Atoi(key)\n\t\t\tif err != nil || index < 0 || index >= len(typed) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tcurrent = typed[index]\n\t\tdefault:\n\t\t\treturn nil\n\t\t}\n\t\tif current == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn current\n}\n\n// reencode converts decoded JSON (maps/slices) into a typed value via a JSON round-trip.\nfunc reencode(raw any, target any) error {\n\tdata, err := json.Marshal(raw)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn json.Unmarshal(data, target)\n}\n\ntype pageCall func(params url.Values) (any, *http.Response, error)\n\n// iterPages yields raw page JSON per the pagination spec — the same stop\n// conditions and infinite-loop guards as the TypeScript runtime. The returned\n// function is a range-over-func iterator (Go 1.23+) and plainly callable before that.\nfunc iterPages(call pageCall, spec PaginationSpec, base url.Values) func(yield func(any, error) bool) {\n\treturn func(yield func(any, error) bool) {\n\t\tswitch spec.Style {\n\t\tcase "cursor":\n\t\t\tvar cursor any\n\t\t\tif values, ok := base[spec.Param]; ok && len(values) > 0 {\n\t\t\t\tcursor = values[0]\n\t\t\t}\n\t\t\tfor {\n\t\t\t\tparams := cloneValues(base)\n\t\t\t\tif cursor != nil {\n\t\t\t\t\tparams.Set(spec.Param, fmt.Sprint(cursor))\n\t\t\t\t}\n\t\t\t\tpage, _, err := call(params)\n\t\t\t\tif err != nil {\n\t\t\t\t\tyield(nil, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif !yield(page, nil) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif spec.HasMore != "" {\n\t\t\t\t\tif more, ok := resolvePointer(page, spec.HasMore).(bool); ok && !more {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tnext := resolvePointer(page, spec.NextCursor)\n\t\t\t\tif next == nil || next == "" {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tswitch next.(type) {\n\t\t\t\tcase string, float64:\n\t\t\t\tdefault:\n\t\t\t\t\tyield(nil, fmt.Errorf("pagination cursor at %s is not a string or number", spec.NextCursor))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif cursor != nil && fmt.Sprint(next) == fmt.Sprint(cursor) {\n\t\t\t\t\tyield(nil, errors.New("pagination did not advance: the operation returned the same cursor twice"))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tcursor = next\n\t\t\t}\n\t\tcase "link":\n\t\t\tparams := cloneValues(base)\n\t\t\tprevious := ""\n\t\t\tfor {\n\t\t\t\tpage, resp, err := call(params)\n\t\t\t\tif err != nil {\n\t\t\t\t\tyield(nil, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif !yield(page, nil) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\ttarget := linkNext(resp.Header.Get("Link"))\n\t\t\t\tif target == "" {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tpageURL := ""\n\t\t\t\tif resp.Request != nil && resp.Request.URL != nil {\n\t\t\t\t\tpageURL = resp.Request.URL.String()\n\t\t\t\t}\n\t\t\t\tbaseURL, err := url.Parse(pageURL)\n\t\t\t\tif err != nil || pageURL == "" {\n\t\t\t\t\tbaseURL, _ = url.Parse("http://relative.invalid")\n\t\t\t\t}\n\t\t\t\ttargetURL, err := baseURL.Parse(target)\n\t\t\t\tif err != nil {\n\t\t\t\t\tyield(nil, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tnext := targetURL.String()\n\t\t\t\tif next == previous || next == pageURL {\n\t\t\t\t\tyield(nil, errors.New(`pagination did not advance: the Link rel="next" target repeats`))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tprevious = next\n\t\t\t\tparams = cloneValues(base)\n\t\t\t\tfor key, values := range targetURL.Query() {\n\t\t\t\t\tfor _, value := range values {\n\t\t\t\t\t\tparams.Add(key, value)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\tdefault: // offset / page\n\t\t\tposition := 0\n\t\t\tif spec.Style == "page" {\n\t\t\t\tposition = 1\n\t\t\t}\n\t\t\tif values, ok := base[spec.Param]; ok && len(values) > 0 && values[0] != "" {\n\t\t\t\tif parsed, err := strconv.Atoi(values[0]); err == nil {\n\t\t\t\t\tposition = parsed\n\t\t\t\t}\n\t\t\t}\n\t\t\tpreviousItems := ""\n\t\t\tfor {\n\t\t\t\tparams := cloneValues(base)\n\t\t\t\tparams.Set(spec.Param, strconv.Itoa(position))\n\t\t\t\tpage, _, err := call(params)\n\t\t\t\tif err != nil {\n\t\t\t\t\tyield(nil, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\titems, _ := resolvePointer(page, spec.Items).([]any)\n\t\t\t\tserialized := ""\n\t\t\t\tif items != nil {\n\t\t\t\t\tserialized = fmt.Sprint(items)\n\t\t\t\t\tif serialized == previousItems {\n\t\t\t\t\t\tyield(nil, errors.New("pagination did not advance: the operation returned the same page twice"))\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif !yield(page, nil) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif len(items) == 0 {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tpreviousItems = serialized\n\t\t\t\tif spec.Style == "page" {\n\t\t\t\t\tposition++\n\t\t\t\t} else {\n\t\t\t\t\tposition += len(items)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc cloneValues(values url.Values) url.Values {\n\tout := url.Values{}\n\tfor key, entries := range values {\n\t\tfor _, entry := range entries {\n\t\t\tout.Add(key, entry)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc linkNext(header string) string {\n\tif header == "" {\n\t\treturn ""\n\t}\n\tfor _, entry := range strings.Split(header, ",") {\n\t\tparts := strings.Split(entry, ";")\n\t\tif len(parts) < 2 {\n\t\t\tcontinue\n\t\t}\n\t\ttarget := strings.TrimSpace(parts[0])\n\t\tif !strings.HasPrefix(target, "<") || !strings.HasSuffix(target, ">") {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, param := range parts[1:] {\n\t\t\ttrimmed := strings.TrimSpace(param)\n\t\t\tif strings.HasPrefix(trimmed, "rel=") {\n\t\t\t\trel := strings.Trim(strings.TrimPrefix(trimmed, "rel="), `"`)\n\t\t\t\tfor _, kind := range strings.Fields(rel) {\n\t\t\t\t\tif kind == "next" {\n\t\t\t\t\t\treturn strings.Trim(target, "<>")\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn ""\n}\n\n// ─── Server-Sent Events ───\n\n// ServerSentEvent is one decoded event; Data is the raw text (or parsed JSON\n// for operations that declare a JSON event stream).\ntype ServerSentEvent struct {\n\tEvent string\n\tData any\n\tID string\n\tRetry int\n}\n\nfunc parseSSEFrame(raw string, jsonData bool) (ServerSentEvent, bool, error) {\n\tevent := ServerSentEvent{Retry: -1}\n\tsawField := false\n\tvar dataLines []string\n\tnormalized := strings.ReplaceAll(strings.ReplaceAll(raw, "\\r\\n", "\\n"), "\\r", "\\n")\n\tfor _, line := range strings.Split(normalized, "\\n") {\n\t\tif line == "" || strings.HasPrefix(line, ":") {\n\t\t\tcontinue\n\t\t}\n\t\tfield, value, _ := strings.Cut(line, ":")\n\t\tvalue = strings.TrimPrefix(value, " ")\n\t\tsawField = true\n\t\tswitch field {\n\t\tcase "event":\n\t\t\tevent.Event = value\n\t\tcase "data":\n\t\t\tdataLines = append(dataLines, value)\n\t\tcase "id":\n\t\t\tevent.ID = value\n\t\tcase "retry":\n\t\t\tif parsed, err := strconv.Atoi(value); err == nil && parsed >= 0 && value != "" {\n\t\t\t\tevent.Retry = parsed\n\t\t\t}\n\t\t}\n\t}\n\tif !sawField {\n\t\treturn event, false, nil\n\t}\n\ttext := strings.Join(dataLines, "\\n")\n\tevent.Data = text\n\tif jsonData && text != "" {\n\t\tvar parsed any\n\t\tif err := json.Unmarshal([]byte(text), &parsed); err != nil {\n\t\t\treturn event, false, err\n\t\t}\n\t\tevent.Data = parsed\n\t}\n\treturn event, true, nil\n}\n\n// iterSSE streams events, reconnecting on dropped connections with Last-Event-ID\n// (a fresh open call = fresh auth); a 4xx/5xx or a bad JSON payload is definitive.\nfunc iterSSE(open func(extraHeaders map[string]string) (*http.Response, error), jsonData bool) func(yield func(ServerSentEvent, error) bool) {\n\treturn func(yield func(ServerSentEvent, error) bool) {\n\t\tlastEventID := ""\n\t\tserverRetry := -1\n\t\tfailures := 0\n\t\tfor {\n\t\t\theaders := map[string]string{"Accept": "text/event-stream"}\n\t\t\tif lastEventID != "" {\n\t\t\t\theaders["Last-Event-ID"] = lastEventID\n\t\t\t}\n\t\t\tresp, err := open(headers)\n\t\t\tif err == nil && resp.StatusCode >= 400 {\n\t\t\t\tyield(ServerSentEvent{}, apiErrorFrom(resp, ""))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err == nil {\n\t\t\t\tfailures = 0\n\t\t\t\tbuffer := ""\n\t\t\t\tchunk := make([]byte, 4096)\n\t\t\t\tclean := false\n\t\t\t\tfor {\n\t\t\t\t\tn, readErr := resp.Body.Read(chunk)\n\t\t\t\t\tbuffer += string(chunk[:n])\n\t\t\t\t\tfor {\n\t\t\t\t\t\tframe, rest, found := strings.Cut(buffer, "\\n\\n")\n\t\t\t\t\t\tif !found {\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t\tbuffer = rest\n\t\t\t\t\t\tevent, ok, parseErr := parseSSEFrame(frame, jsonData)\n\t\t\t\t\t\tif parseErr != nil {\n\t\t\t\t\t\t\tresp.Body.Close()\n\t\t\t\t\t\t\tyield(ServerSentEvent{}, parseErr)\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif ok {\n\t\t\t\t\t\t\tif event.ID != "" {\n\t\t\t\t\t\t\t\tlastEventID = event.ID\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif event.Retry >= 0 {\n\t\t\t\t\t\t\t\tserverRetry = event.Retry\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif !yield(event, nil) {\n\t\t\t\t\t\t\t\tresp.Body.Close()\n\t\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif readErr == io.EOF {\n\t\t\t\t\t\tclean = true\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\tif readErr != nil {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tresp.Body.Close()\n\t\t\t\tif clean {\n\t\t\t\t\tif strings.TrimSpace(buffer) != "" {\n\t\t\t\t\t\tif event, ok, parseErr := parseSSEFrame(buffer, jsonData); parseErr == nil && ok {\n\t\t\t\t\t\t\tyield(event, nil)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tfailures++\n\t\t\tbase := time.Second\n\t\t\tif serverRetry >= 0 {\n\t\t\t\tbase = time.Duration(serverRetry) * time.Millisecond\n\t\t\t}\n\t\t\tdelay := base * time.Duration(1<<(failures-1))\n\t\t\tif delay > 30*time.Second {\n\t\t\t\tdelay = 30 * time.Second\n\t\t\t}\n\t\t\ttime.Sleep(time.Duration(rand.Int63n(int64(delay) + 1)))\n\t\t}\n\t}\n}\n\n// ─── Multipart ───\n\n// toMultipart splits a typed body into a multipart/form-data payload: []byte\n// values upload as file parts, everything else as form fields (nested values\n// JSON-encoded) — mirroring the TypeScript runtime\'s FormData serialization.\nfunc toMultipart(body any) (string, io.Reader, error) {\n\tvar wire map[string]any\n\tif err := reencode(body, &wire); err != nil {\n\t\treturn "", nil, err\n\t}\n\tbuffer := &bytes.Buffer{}\n\twriter := multipart.NewWriter(buffer)\n\tfor key, value := range wire {\n\t\tswitch typed := value.(type) {\n\t\tcase string:\n\t\t\tif err := writer.WriteField(key, typed); err != nil {\n\t\t\t\treturn "", nil, err\n\t\t\t}\n\t\tcase float64, bool:\n\t\t\tif err := writer.WriteField(key, fmt.Sprint(typed)); err != nil {\n\t\t\t\treturn "", nil, err\n\t\t\t}\n\t\tdefault:\n\t\t\tencoded, err := json.Marshal(typed)\n\t\t\tif err != nil {\n\t\t\t\treturn "", nil, err\n\t\t\t}\n\t\t\tif err := writer.WriteField(key, string(encoded)); err != nil {\n\t\t\t\treturn "", nil, err\n\t\t\t}\n\t\t}\n\t}\n\tif err := writer.Close(); err != nil {\n\t\treturn "", nil, err\n\t}\n\treturn writer.FormDataContentType(), buffer, nil\n}\n'; + '// Package client — the embedded runtime for generated Go SDKs. Hand-authored\n// once and stitched into every generated client (see\n// scripts/generate-runtime-sources.mjs), semantically in lockstep with the\n// TypeScript runtime: auth OR-alternatives, a retry loop with Retry-After and\n// full-jitter backoff, per-attempt timeouts, idempotency keys, and middleware\n// hooks. Standard library only — a generated Go SDK has zero dependencies.\npackage client\n\nimport (\n\t"bytes"\n\t"context"\n\t"encoding/base64"\n\t"encoding/json"\n\t"errors"\n\t"fmt"\n\t"io"\n\t"math/rand"\n\t"mime/multipart"\n\t"net/http"\n\t"net/url"\n\t"strconv"\n\t"strings"\n\t"time"\n)\n\n// APIError is returned for a non-2xx response, carrying the decoded error body.\ntype APIError struct {\n\tURL string\n\tStatus int\n\tStatusText string\n\tBody any\n}\n\nfunc (e *APIError) Error() string {\n\treturn fmt.Sprintf("request failed with status %d", e.Status)\n}\n\n// TimeoutError is returned when a request attempt exceeds the configured\n// timeout — carrying the context a log line needs.\ntype TimeoutError struct {\n\tOperationID string\n\tTimeout time.Duration\n\tAttempt int\n}\n\nfunc (e *TimeoutError) Error() string {\n\treturn fmt.Sprintf("request %q timed out after %s (attempt %d)", e.OperationID, e.Timeout, e.Attempt)\n}\n\n// SecuritySpec mirrors the descriptor table\'s security entries.\ntype SecuritySpec struct {\n\tScheme string\n\tKind string // "bearer" | "basic" | "apiKey"\n\tName string // header/query/cookie name for apiKey\n\tIn string // "header" | "query" | "cookie"\n}\n\n// Auth holds the client credentials; zero value = anonymous.\ntype Auth struct {\n\tBearer func() string\n\tBasic *BasicAuth\n\tAPIKey map[string]func() string\n}\n\ntype BasicAuth struct {\n\tUsername string\n\tPassword string\n}\n\n// RetryConfig mirrors the TypeScript runtime\'s retry policy knobs.\ntype RetryConfig struct {\n\tRetries int\n\tRetryDelay time.Duration // base; default 1s\n\tRetryStrategy string // "" (exponential) | "fixed"\n\tNoJitter bool\n\t// RetryOn fully replaces the default predicate when set.\n\tRetryOn func(attempt int, resp *http.Response, err error) bool\n}\n\n// Middleware hooks run around every request (OnRequest before serialization order\n// is N/A in Go — bodies are values; OnResponse runs in reverse registration order).\ntype Middleware struct {\n\tOnRequest func(req *http.Request)\n\tOnResponse func(resp *http.Response)\n}\n\n// Config is the per-client configuration shared by every operation method.\ntype Config struct {\n\tServerURL string\n\tHTTPClient *http.Client\n\tHeaders map[string]string\n\tTimeout time.Duration\n\tRetry RetryConfig\n\tMiddleware []Middleware\n\tIdempotencyKey func() string\n\tAuth Auth\n}\n\nfunc resolveToken(provider func() string) string {\n\tif provider == nil {\n\t\treturn ""\n\t}\n\treturn provider()\n}\n\nfunc schemeConfigured(spec SecuritySpec, auth Auth) bool {\n\tswitch spec.Kind {\n\tcase "apiKey":\n\t\t_, ok := auth.APIKey[spec.Scheme]\n\t\treturn ok\n\tcase "bearer":\n\t\treturn auth.Bearer != nil\n\tdefault:\n\t\treturn auth.Basic != nil\n\t}\n}\n\n// resolveAuth applies the first fully-configured OR-alternative; when none is,\n// the first alternative\'s configured schemes are still sent (the server rejects\n// the request — same behavior as the TypeScript runtime).\nfunc resolveAuth(security [][]SecuritySpec, auth Auth) (map[string]string, url.Values) {\n\theaders := map[string]string{}\n\tquery := url.Values{}\n\tif len(security) == 0 {\n\t\treturn headers, query\n\t}\n\talternative := security[0]\n\tfor _, candidate := range security {\n\t\tall := true\n\t\tfor _, spec := range candidate {\n\t\t\tif !schemeConfigured(spec, auth) {\n\t\t\t\tall = false\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif all {\n\t\t\talternative = candidate\n\t\t\tbreak\n\t\t}\n\t}\n\tvar cookies []string\n\tfor _, spec := range alternative {\n\t\tswitch spec.Kind {\n\t\tcase "apiKey":\n\t\t\tprovider, ok := auth.APIKey[spec.Scheme]\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalue := resolveToken(provider)\n\t\t\tswitch spec.In {\n\t\t\tcase "query":\n\t\t\t\tquery.Set(spec.Name, value)\n\t\t\tcase "cookie":\n\t\t\t\tcookies = append(cookies, spec.Name+"="+url.QueryEscape(value))\n\t\t\tdefault:\n\t\t\t\theaders[spec.Name] = value\n\t\t\t}\n\t\tcase "bearer":\n\t\t\tif auth.Bearer != nil {\n\t\t\t\theaders["Authorization"] = "Bearer " + resolveToken(auth.Bearer)\n\t\t\t}\n\t\tdefault:\n\t\t\tif auth.Basic != nil {\n\t\t\t\ttoken := base64.StdEncoding.EncodeToString([]byte(auth.Basic.Username + ":" + auth.Basic.Password))\n\t\t\t\theaders["Authorization"] = "Basic " + token\n\t\t\t}\n\t\t}\n\t}\n\tif len(cookies) > 0 {\n\t\theaders["Cookie"] = strings.Join(cookies, "; ")\n\t}\n\treturn headers, query\n}\n\n// buildURL substitutes {param} path placeholders with percent-encoded values.\nfunc buildURL(serverURL, path string, pathParams map[string]string) string {\n\tfilled := path\n\tfor name, value := range pathParams {\n\t\tfilled = strings.ReplaceAll(filled, "{"+name+"}", url.PathEscape(value))\n\t}\n\treturn strings.TrimRight(serverURL, "/") + filled\n}\n\nvar transientStatus = map[int]bool{408: true, 429: true, 500: true, 502: true, 503: true, 504: true}\n\nfunc defaultRetryOn(method string, headers map[string]string, resp *http.Response, err error) bool {\n\tsafe := false\n\tswitch strings.ToUpper(method) {\n\tcase "GET", "HEAD", "PUT", "DELETE", "OPTIONS":\n\t\tsafe = true\n\t}\n\tif _, ok := headers["Idempotency-Key"]; ok {\n\t\tsafe = true\n\t}\n\tif !safe {\n\t\treturn false\n\t}\n\tif err != nil {\n\t\treturn true\n\t}\n\treturn resp != nil && transientStatus[resp.StatusCode]\n}\n\nfunc retryDelay(retry RetryConfig, attempt int, retryAfter string) time.Duration {\n\tif retryAfter != "" {\n\t\tif seconds, err := strconv.ParseFloat(retryAfter, 64); err == nil {\n\t\t\treturn time.Duration(seconds * float64(time.Second))\n\t\t}\n\t}\n\tbase := retry.RetryDelay\n\tif base == 0 {\n\t\tbase = time.Second\n\t}\n\traw := base\n\tif retry.RetryStrategy != "fixed" {\n\t\traw = base * time.Duration(1<<(attempt-1))\n\t}\n\tif retry.NoJitter {\n\t\treturn raw\n\t}\n\treturn time.Duration(rand.Int63n(int64(raw) + 1))\n}\n\ntype requestSpec struct {\n\tOperationID string\n\tMethod string\n\tURL string\n\tHeaders map[string]string\n\tQuery url.Values\n\tBody io.Reader\n\tContentType string\n\tTimeout time.Duration\n\tRetry *RetryConfig\n\tIdempotencyKey string\n\t// bodyBytes is retained so retries can replay the body.\n\tbodyBytes []byte\n}\n\n// send is the request core: header merge, idempotency keys, the retry loop\n// (fresh timeout budget per attempt), and the middleware onion.\nfunc send(ctx context.Context, config *Config, spec requestSpec) (*http.Response, error) {\n\tretry := config.Retry\n\tif spec.Retry != nil {\n\t\tretry = *spec.Retry\n\t}\n\ttimeout := config.Timeout\n\tif spec.Timeout != 0 {\n\t\ttimeout = spec.Timeout\n\t}\n\theaders := map[string]string{}\n\tfor key, value := range config.Headers {\n\t\theaders[key] = value\n\t}\n\tfor key, value := range spec.Headers {\n\t\theaders[key] = value\n\t}\n\tmethod := strings.ToUpper(spec.Method)\n\tif (method == "POST" || method == "PATCH") && headers["Idempotency-Key"] == "" {\n\t\tif spec.IdempotencyKey != "" {\n\t\t\theaders["Idempotency-Key"] = spec.IdempotencyKey\n\t\t} else if config.IdempotencyKey != nil {\n\t\t\theaders["Idempotency-Key"] = config.IdempotencyKey()\n\t\t}\n\t}\n\thttpClient := config.HTTPClient\n\tif httpClient == nil {\n\t\thttpClient = http.DefaultClient\n\t}\n\tif spec.Body != nil {\n\t\tpayload, err := io.ReadAll(spec.Body)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tspec.bodyBytes = payload\n\t}\n\tfullURL := spec.URL\n\tif len(spec.Query) > 0 {\n\t\tseparator := "?"\n\t\tif strings.Contains(fullURL, "?") {\n\t\t\tseparator = "&"\n\t\t}\n\t\tfullURL += separator + spec.Query.Encode()\n\t}\n\tmaxAttempts := 1 + retry.Retries\n\tfor attempt := 1; ; attempt++ {\n\t\tattemptCtx := ctx\n\t\tvar cancel context.CancelFunc\n\t\tif timeout > 0 {\n\t\t\tattemptCtx, cancel = context.WithTimeout(ctx, timeout)\n\t\t}\n\t\tvar bodyReader io.Reader\n\t\tif spec.bodyBytes != nil {\n\t\t\tbodyReader = bytes.NewReader(spec.bodyBytes)\n\t\t}\n\t\treq, err := http.NewRequestWithContext(attemptCtx, method, fullURL, bodyReader)\n\t\tif err != nil {\n\t\t\tif cancel != nil {\n\t\t\t\tcancel()\n\t\t\t}\n\t\t\treturn nil, err\n\t\t}\n\t\tfor key, value := range headers {\n\t\t\treq.Header.Set(key, value)\n\t\t}\n\t\tif spec.ContentType != "" && spec.bodyBytes != nil {\n\t\t\treq.Header.Set("Content-Type", spec.ContentType)\n\t\t}\n\t\tfor _, mw := range config.Middleware {\n\t\t\tif mw.OnRequest != nil {\n\t\t\t\tmw.OnRequest(req)\n\t\t\t}\n\t\t}\n\t\tresp, err := httpClient.Do(req)\n\t\tshouldRetry := retry.RetryOn\n\t\tretryable := false\n\t\tif shouldRetry != nil {\n\t\t\tretryable = shouldRetry(attempt, resp, err)\n\t\t} else {\n\t\t\tretryable = defaultRetryOn(method, headers, resp, err)\n\t\t}\n\t\tif err != nil {\n\t\t\tif cancel != nil {\n\t\t\t\tcancel()\n\t\t\t}\n\t\t\ttimedOut := errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil\n\t\t\tif attempt < maxAttempts && retryable {\n\t\t\t\ttime.Sleep(retryDelay(retry, attempt, ""))\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif timedOut {\n\t\t\t\treturn nil, &TimeoutError{OperationID: spec.OperationID, Timeout: timeout, Attempt: attempt}\n\t\t\t}\n\t\t\treturn nil, err\n\t\t}\n\t\tfor i := len(config.Middleware) - 1; i >= 0; i-- {\n\t\t\tif config.Middleware[i].OnResponse != nil {\n\t\t\t\tconfig.Middleware[i].OnResponse(resp)\n\t\t\t}\n\t\t}\n\t\tif resp.StatusCode >= 400 && attempt < maxAttempts && retryable {\n\t\t\tafter := resp.Header.Get("Retry-After")\n\t\t\tio.Copy(io.Discard, resp.Body)\n\t\t\tresp.Body.Close()\n\t\t\tif cancel != nil {\n\t\t\t\tcancel()\n\t\t\t}\n\t\t\ttime.Sleep(retryDelay(retry, attempt, after))\n\t\t\tcontinue\n\t\t}\n\t\t// The response body outlives this call; tie the attempt context\'s lifetime to it.\n\t\tif cancel != nil {\n\t\t\tresp.Body = &cancelOnClose{ReadCloser: resp.Body, cancel: cancel}\n\t\t}\n\t\treturn resp, nil\n\t}\n}\n\ntype cancelOnClose struct {\n\tio.ReadCloser\n\tcancel context.CancelFunc\n}\n\nfunc (c *cancelOnClose) Close() error {\n\tc.cancel()\n\treturn c.ReadCloser.Close()\n}\n\n// decodeJSON decodes a response body into target; a nil target drains and closes.\nfunc decodeJSON(resp *http.Response, target any) error {\n\tdefer resp.Body.Close()\n\tif target == nil {\n\t\t_, err := io.Copy(io.Discard, resp.Body)\n\t\treturn err\n\t}\n\treturn json.NewDecoder(resp.Body).Decode(target)\n}\n\n// headerString returns the named response header, or nil when absent.\nfunc headerString(header http.Header, name string) *string {\n\tvalue := header.Get(name)\n\tif value == "" {\n\t\treturn nil\n\t}\n\treturn &value\n}\n\n// headerInt64 parses the named header as an integer; nil when absent or unparsable.\nfunc headerInt64(header http.Header, name string) *int64 {\n\traw := strings.TrimSpace(header.Get(name))\n\tif raw == "" {\n\t\treturn nil\n\t}\n\tvalue, err := strconv.ParseInt(raw, 10, 64)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn &value\n}\n\n// headerFloat64 parses the named header as a number; nil when absent or unparsable.\nfunc headerFloat64(header http.Header, name string) *float64 {\n\traw := strings.TrimSpace(header.Get(name))\n\tif raw == "" {\n\t\treturn nil\n\t}\n\tvalue, err := strconv.ParseFloat(raw, 64)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn &value\n}\n\n// headerBool parses a `true`/`false` header; nil when absent or anything else.\nfunc headerBool(header http.Header, name string) *bool {\n\traw := strings.ToLower(strings.TrimSpace(header.Get(name)))\n\tif raw != "true" && raw != "false" {\n\t\treturn nil\n\t}\n\tvalue := raw == "true"\n\treturn &value\n}\n\n// apiErrorFrom builds the structured error for a non-2xx response.\nfunc apiErrorFrom(resp *http.Response, requestURL string) error {\n\tdefer resp.Body.Close()\n\tvar body any\n\tdata, _ := io.ReadAll(resp.Body)\n\tif len(data) > 0 {\n\t\tif err := json.Unmarshal(data, &body); err != nil {\n\t\t\tbody = string(data)\n\t\t}\n\t}\n\treturn &APIError{URL: requestURL, Status: resp.StatusCode, StatusText: resp.Status, Body: body}\n}\n\n// ─── Pagination ───\n\n// PaginationSpec mirrors the descriptor table\'s pagination entries.\ntype PaginationSpec struct {\n\tStyle string\n\tParam string\n\tNextCursor string\n\tHasMore string\n\tLimitParam string\n\tItems string\n}\n\n// resolvePointer walks an RFC 6901 JSON pointer over decoded JSON; nil on any miss.\nfunc resolvePointer(data any, pointer string) any {\n\tif pointer == "" {\n\t\treturn data\n\t}\n\tif !strings.HasPrefix(pointer, "/") {\n\t\treturn nil\n\t}\n\tcurrent := data\n\tfor _, token := range strings.Split(pointer[1:], "/") {\n\t\tkey := strings.ReplaceAll(strings.ReplaceAll(token, "~1", "/"), "~0", "~")\n\t\tswitch typed := current.(type) {\n\t\tcase map[string]any:\n\t\t\tcurrent = typed[key]\n\t\tcase []any:\n\t\t\tindex, err := strconv.Atoi(key)\n\t\t\tif err != nil || index < 0 || index >= len(typed) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tcurrent = typed[index]\n\t\tdefault:\n\t\t\treturn nil\n\t\t}\n\t\tif current == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn current\n}\n\n// reencode converts decoded JSON (maps/slices) into a typed value via a JSON round-trip.\nfunc reencode(raw any, target any) error {\n\tdata, err := json.Marshal(raw)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn json.Unmarshal(data, target)\n}\n\ntype pageCall func(params url.Values) (any, *http.Response, error)\n\n// iterPages yields raw page JSON per the pagination spec — the same stop\n// conditions and infinite-loop guards as the TypeScript runtime. The returned\n// function is a range-over-func iterator (Go 1.23+) and plainly callable before that.\nfunc iterPages(call pageCall, spec PaginationSpec, base url.Values) func(yield func(any, error) bool) {\n\treturn func(yield func(any, error) bool) {\n\t\tswitch spec.Style {\n\t\tcase "cursor":\n\t\t\tvar cursor any\n\t\t\tif values, ok := base[spec.Param]; ok && len(values) > 0 {\n\t\t\t\tcursor = values[0]\n\t\t\t}\n\t\t\tfor {\n\t\t\t\tparams := cloneValues(base)\n\t\t\t\tif cursor != nil {\n\t\t\t\t\tparams.Set(spec.Param, fmt.Sprint(cursor))\n\t\t\t\t}\n\t\t\t\tpage, _, err := call(params)\n\t\t\t\tif err != nil {\n\t\t\t\t\tyield(nil, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif !yield(page, nil) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif spec.HasMore != "" {\n\t\t\t\t\tif more, ok := resolvePointer(page, spec.HasMore).(bool); ok && !more {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tnext := resolvePointer(page, spec.NextCursor)\n\t\t\t\tif next == nil || next == "" {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tswitch next.(type) {\n\t\t\t\tcase string, float64:\n\t\t\t\tdefault:\n\t\t\t\t\tyield(nil, fmt.Errorf("pagination cursor at %s is not a string or number", spec.NextCursor))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif cursor != nil && fmt.Sprint(next) == fmt.Sprint(cursor) {\n\t\t\t\t\tyield(nil, errors.New("pagination did not advance: the operation returned the same cursor twice"))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tcursor = next\n\t\t\t}\n\t\tcase "link":\n\t\t\tparams := cloneValues(base)\n\t\t\tprevious := ""\n\t\t\tfor {\n\t\t\t\tpage, resp, err := call(params)\n\t\t\t\tif err != nil {\n\t\t\t\t\tyield(nil, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif !yield(page, nil) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\ttarget := linkNext(resp.Header.Get("Link"))\n\t\t\t\tif target == "" {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tpageURL := ""\n\t\t\t\tif resp.Request != nil && resp.Request.URL != nil {\n\t\t\t\t\tpageURL = resp.Request.URL.String()\n\t\t\t\t}\n\t\t\t\tbaseURL, err := url.Parse(pageURL)\n\t\t\t\tif err != nil || pageURL == "" {\n\t\t\t\t\tbaseURL, _ = url.Parse("http://relative.invalid")\n\t\t\t\t}\n\t\t\t\ttargetURL, err := baseURL.Parse(target)\n\t\t\t\tif err != nil {\n\t\t\t\t\tyield(nil, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tnext := targetURL.String()\n\t\t\t\tif next == previous || next == pageURL {\n\t\t\t\t\tyield(nil, errors.New(`pagination did not advance: the Link rel="next" target repeats`))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tprevious = next\n\t\t\t\tparams = cloneValues(base)\n\t\t\t\tfor key, values := range targetURL.Query() {\n\t\t\t\t\tfor _, value := range values {\n\t\t\t\t\t\tparams.Add(key, value)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\tdefault: // offset / page\n\t\t\tposition := 0\n\t\t\tif spec.Style == "page" {\n\t\t\t\tposition = 1\n\t\t\t}\n\t\t\tif values, ok := base[spec.Param]; ok && len(values) > 0 && values[0] != "" {\n\t\t\t\tif parsed, err := strconv.Atoi(values[0]); err == nil {\n\t\t\t\t\tposition = parsed\n\t\t\t\t}\n\t\t\t}\n\t\t\tpreviousItems := ""\n\t\t\tfor {\n\t\t\t\tparams := cloneValues(base)\n\t\t\t\tparams.Set(spec.Param, strconv.Itoa(position))\n\t\t\t\tpage, _, err := call(params)\n\t\t\t\tif err != nil {\n\t\t\t\t\tyield(nil, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\titems, _ := resolvePointer(page, spec.Items).([]any)\n\t\t\t\tserialized := ""\n\t\t\t\tif items != nil {\n\t\t\t\t\tserialized = fmt.Sprint(items)\n\t\t\t\t\tif serialized == previousItems {\n\t\t\t\t\t\tyield(nil, errors.New("pagination did not advance: the operation returned the same page twice"))\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif !yield(page, nil) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif len(items) == 0 {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tpreviousItems = serialized\n\t\t\t\tif spec.Style == "page" {\n\t\t\t\t\tposition++\n\t\t\t\t} else {\n\t\t\t\t\tposition += len(items)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc cloneValues(values url.Values) url.Values {\n\tout := url.Values{}\n\tfor key, entries := range values {\n\t\tfor _, entry := range entries {\n\t\t\tout.Add(key, entry)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc linkNext(header string) string {\n\tif header == "" {\n\t\treturn ""\n\t}\n\tfor _, entry := range strings.Split(header, ",") {\n\t\tparts := strings.Split(entry, ";")\n\t\tif len(parts) < 2 {\n\t\t\tcontinue\n\t\t}\n\t\ttarget := strings.TrimSpace(parts[0])\n\t\tif !strings.HasPrefix(target, "<") || !strings.HasSuffix(target, ">") {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, param := range parts[1:] {\n\t\t\ttrimmed := strings.TrimSpace(param)\n\t\t\tif strings.HasPrefix(trimmed, "rel=") {\n\t\t\t\trel := strings.Trim(strings.TrimPrefix(trimmed, "rel="), `"`)\n\t\t\t\tfor _, kind := range strings.Fields(rel) {\n\t\t\t\t\tif kind == "next" {\n\t\t\t\t\t\treturn strings.Trim(target, "<>")\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn ""\n}\n\n// ─── Server-Sent Events ───\n\n// ServerSentEvent is one decoded event; Data is the raw text (or parsed JSON\n// for operations that declare a JSON event stream).\ntype ServerSentEvent struct {\n\tEvent string\n\tData any\n\tID string\n\tRetry int\n}\n\nfunc parseSSEFrame(raw string, jsonData bool) (ServerSentEvent, bool, error) {\n\tevent := ServerSentEvent{Retry: -1}\n\tsawField := false\n\tvar dataLines []string\n\tnormalized := strings.ReplaceAll(strings.ReplaceAll(raw, "\\r\\n", "\\n"), "\\r", "\\n")\n\tfor _, line := range strings.Split(normalized, "\\n") {\n\t\tif line == "" || strings.HasPrefix(line, ":") {\n\t\t\tcontinue\n\t\t}\n\t\tfield, value, _ := strings.Cut(line, ":")\n\t\tvalue = strings.TrimPrefix(value, " ")\n\t\tsawField = true\n\t\tswitch field {\n\t\tcase "event":\n\t\t\tevent.Event = value\n\t\tcase "data":\n\t\t\tdataLines = append(dataLines, value)\n\t\tcase "id":\n\t\t\tevent.ID = value\n\t\tcase "retry":\n\t\t\tif parsed, err := strconv.Atoi(value); err == nil && parsed >= 0 && value != "" {\n\t\t\t\tevent.Retry = parsed\n\t\t\t}\n\t\t}\n\t}\n\tif !sawField {\n\t\treturn event, false, nil\n\t}\n\ttext := strings.Join(dataLines, "\\n")\n\tevent.Data = text\n\tif jsonData && text != "" {\n\t\tvar parsed any\n\t\tif err := json.Unmarshal([]byte(text), &parsed); err != nil {\n\t\t\treturn event, false, err\n\t\t}\n\t\tevent.Data = parsed\n\t}\n\treturn event, true, nil\n}\n\n// iterSSE streams events, reconnecting on dropped connections with Last-Event-ID\n// (a fresh open call = fresh auth); a 4xx/5xx or a bad JSON payload is definitive.\nfunc iterSSE(open func(extraHeaders map[string]string) (*http.Response, error), jsonData bool) func(yield func(ServerSentEvent, error) bool) {\n\treturn func(yield func(ServerSentEvent, error) bool) {\n\t\tlastEventID := ""\n\t\tserverRetry := -1\n\t\tfailures := 0\n\t\tfor {\n\t\t\theaders := map[string]string{"Accept": "text/event-stream"}\n\t\t\tif lastEventID != "" {\n\t\t\t\theaders["Last-Event-ID"] = lastEventID\n\t\t\t}\n\t\t\tresp, err := open(headers)\n\t\t\tif err == nil && resp.StatusCode >= 400 {\n\t\t\t\tyield(ServerSentEvent{}, apiErrorFrom(resp, ""))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err == nil {\n\t\t\t\tfailures = 0\n\t\t\t\tbuffer := ""\n\t\t\t\tchunk := make([]byte, 4096)\n\t\t\t\tclean := false\n\t\t\t\tfor {\n\t\t\t\t\tn, readErr := resp.Body.Read(chunk)\n\t\t\t\t\tbuffer += string(chunk[:n])\n\t\t\t\t\tfor {\n\t\t\t\t\t\tframe, rest, found := strings.Cut(buffer, "\\n\\n")\n\t\t\t\t\t\tif !found {\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t\tbuffer = rest\n\t\t\t\t\t\tevent, ok, parseErr := parseSSEFrame(frame, jsonData)\n\t\t\t\t\t\tif parseErr != nil {\n\t\t\t\t\t\t\tresp.Body.Close()\n\t\t\t\t\t\t\tyield(ServerSentEvent{}, parseErr)\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif ok {\n\t\t\t\t\t\t\tif event.ID != "" {\n\t\t\t\t\t\t\t\tlastEventID = event.ID\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif event.Retry >= 0 {\n\t\t\t\t\t\t\t\tserverRetry = event.Retry\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif !yield(event, nil) {\n\t\t\t\t\t\t\t\tresp.Body.Close()\n\t\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif readErr == io.EOF {\n\t\t\t\t\t\tclean = true\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\tif readErr != nil {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tresp.Body.Close()\n\t\t\t\tif clean {\n\t\t\t\t\tif strings.TrimSpace(buffer) != "" {\n\t\t\t\t\t\tif event, ok, parseErr := parseSSEFrame(buffer, jsonData); parseErr == nil && ok {\n\t\t\t\t\t\t\tyield(event, nil)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tfailures++\n\t\t\tbase := time.Second\n\t\t\tif serverRetry >= 0 {\n\t\t\t\tbase = time.Duration(serverRetry) * time.Millisecond\n\t\t\t}\n\t\t\tdelay := base * time.Duration(1<<(failures-1))\n\t\t\tif delay > 30*time.Second {\n\t\t\t\tdelay = 30 * time.Second\n\t\t\t}\n\t\t\ttime.Sleep(time.Duration(rand.Int63n(int64(delay) + 1)))\n\t\t}\n\t}\n}\n\n// ─── Multipart ───\n\n// toMultipart splits a typed body into a multipart/form-data payload: []byte\n// values upload as file parts, everything else as form fields (nested values\n// JSON-encoded) — mirroring the TypeScript runtime\'s FormData serialization.\nfunc toMultipart(body any) (string, io.Reader, error) {\n\tvar wire map[string]any\n\tif err := reencode(body, &wire); err != nil {\n\t\treturn "", nil, err\n\t}\n\tbuffer := &bytes.Buffer{}\n\twriter := multipart.NewWriter(buffer)\n\tfor key, value := range wire {\n\t\tswitch typed := value.(type) {\n\t\tcase string:\n\t\t\tif err := writer.WriteField(key, typed); err != nil {\n\t\t\t\treturn "", nil, err\n\t\t\t}\n\t\tcase float64, bool:\n\t\t\tif err := writer.WriteField(key, fmt.Sprint(typed)); err != nil {\n\t\t\t\treturn "", nil, err\n\t\t\t}\n\t\tdefault:\n\t\t\tencoded, err := json.Marshal(typed)\n\t\t\tif err != nil {\n\t\t\t\treturn "", nil, err\n\t\t\t}\n\t\t\tif err := writer.WriteField(key, string(encoded)); err != nil {\n\t\t\t\treturn "", nil, err\n\t\t\t}\n\t\t}\n\t}\n\tif err := writer.Close(); err != nil {\n\t\treturn "", nil, err\n\t}\n\treturn writer.FormDataContentType(), buffer, nil\n}\n'; diff --git a/packages/client-generator/src/emitters/php-runtime-sources.ts b/packages/client-generator/src/emitters/php-runtime-sources.ts index c46816b432..f354dc66da 100644 --- a/packages/client-generator/src/emitters/php-runtime-sources.ts +++ b/packages/client-generator/src/emitters/php-runtime-sources.ts @@ -1,3 +1,3 @@ // GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`. export const PHP_RUNTIME_SOURCE = - "= 8.1, zero Composer dependencies; HTTP over the curl extension.\n// The generated file re-declares the namespace; the embed strips this header.\n\ndeclare(strict_types=1);\n\nnamespace RedoclyClientRuntime;\n\n/** A response with status >= 400, decoded body attached. */\nfinal class ApiError extends \\RuntimeException\n{\n public function __construct(\n public readonly string $url,\n public readonly int $status,\n public readonly string $reason,\n public readonly mixed $body,\n ) {\n parent::__construct(\"HTTP {$status} {$reason} for {$url}\");\n }\n}\n\n/** Every attempt timed out or failed to connect. */\nfinal class TimeoutError extends \\RuntimeException\n{\n public function __construct(\n public readonly string $url,\n public readonly ?float $timeout,\n public readonly int $attempts,\n ) {\n $seconds = $timeout === null ? 'the configured timeout' : \"{$timeout}s\";\n parent::__construct(\"Request to {$url} timed out after {$seconds} ({$attempts} attempt(s))\");\n }\n}\n\n/** One parsed `text/event-stream` frame. */\nfinal class ServerSentEvent\n{\n public function __construct(\n public readonly string $event,\n public readonly mixed $data,\n public readonly ?string $id = null,\n public readonly ?int $retry = null,\n ) {\n }\n}\n\n/**\n * Per-instance configuration.\n * `auth`: `['bearer' => string|callable, 'basic' => ['username' => ..., 'password' => ...], 'apiKey' => [scheme => string|callable]]`.\n * `retry`: `['attempts' => int, 'delay' => float, 'strategy' => 'exponential'|'fixed', 'retryOn' => callable]`.\n * `middleware`: callables `fn(array $request, callable $next): array` around each attempt.\n */\nfinal class Config\n{\n public function __construct(\n public string $serverUrl = '',\n public array $auth = [],\n public ?float $timeout = null,\n public array $retry = [],\n public array $middleware = [],\n public string $clientHeader = 'redocly-client-generator',\n ) {\n }\n}\n\n/** Resolve a literal-or-callable credential to its string value. */\nfunction resolveToken(mixed $provider): string\n{\n return is_callable($provider) ? (string) $provider() : (string) $provider;\n}\n\n/**\n * Apply the first fully-configured security alternative. `$security` is an OR-list\n * of AND-sets of specs: `['kind' => 'bearer'|'basic'|'apiKey', 'scheme' => ..., 'name' => ?, 'in' => ?]`.\n * Returns `[headers, query, cookies]`.\n */\nfunction resolveAuth(array $security, array $auth): array\n{\n foreach ($security as $andSet) {\n $headers = [];\n $query = [];\n $cookies = [];\n $satisfied = true;\n foreach ($andSet as $spec) {\n if ($spec['kind'] === 'bearer' && isset($auth['bearer'])) {\n $headers['Authorization'] = 'Bearer ' . resolveToken($auth['bearer']);\n } elseif ($spec['kind'] === 'basic' && isset($auth['basic'])) {\n $headers['Authorization'] =\n 'Basic ' . base64_encode($auth['basic']['username'] . ':' . $auth['basic']['password']);\n } elseif ($spec['kind'] === 'apiKey' && isset($auth['apiKey'][$spec['scheme']])) {\n $value = resolveToken($auth['apiKey'][$spec['scheme']]);\n if ($spec['in'] === 'query') {\n $query[$spec['name']] = $value;\n } elseif ($spec['in'] === 'cookie') {\n $cookies[] = $spec['name'] . '=' . rawurlencode($value);\n } else {\n $headers[$spec['name']] = $value;\n }\n } else {\n $satisfied = false;\n break;\n }\n }\n if ($satisfied) {\n return [$headers, $query, $cookies];\n }\n }\n return [[], [], []];\n}\n\n/** Substitute `{param}` templates with encoded values and prefix the server URL. */\nfunction buildUrl(string $serverUrl, string $path, array $pathParams): string\n{\n foreach ($pathParams as $name => $value) {\n $path = str_replace('{' . $name . '}', rawurlencode((string) $value), $path);\n }\n return rtrim($serverUrl, '/') . $path;\n}\n\n/** The default retry predicate: 5xx, 429, and transport timeouts/connect failures. */\nfunction defaultRetryOn(array $context): bool\n{\n if (($context['timedOut'] ?? false) === true) {\n return true;\n }\n $status = $context['status'] ?? 0;\n return $status >= 500 || $status === 429;\n}\n\n/** Delay before the next attempt: `Retry-After` wins; otherwise jittered (fixed|exponential) backoff. */\nfunction retryDelay(int $attempt, array $retry, ?string $retryAfter): float\n{\n if ($retryAfter !== null && ctype_digit($retryAfter)) {\n return (float) $retryAfter;\n }\n $base = (float) ($retry['delay'] ?? 1.0);\n $strategy = $retry['strategy'] ?? 'exponential';\n $delay = $strategy === 'fixed' ? $base : $base * (2 ** ($attempt - 1));\n return $delay * (0.5 + mt_rand() / mt_getrandmax() / 2);\n}\n\n/** Append query params in form style: list values repeat the key (`tag=a&tag=b`). */\nfunction appendQuery(string $url, array $query): string\n{\n $pairs = [];\n foreach ($query as $name => $value) {\n foreach (is_array($value) ? $value : [$value] as $single) {\n $encoded = is_bool($single) ? ($single ? 'true' : 'false') : (string) $single;\n $pairs[] = rawurlencode($name) . '=' . rawurlencode($encoded);\n }\n }\n if ($pairs === []) {\n return $url;\n }\n return $url . (str_contains($url, '?') ? '&' : '?') . implode('&', $pairs);\n}\n\n/** One raw curl exchange. Returns `['status', 'reason', 'headers', 'body', 'url', 'timedOut']`. */\nfunction rawSend(Config $config, array $request): array\n{\n $url = appendQuery($request['url'], $request['query'] ?? []);\n $handle = curl_init($url);\n $headerLines = [];\n foreach ($request['headers'] ?? [] as $name => $value) {\n $headerLines[] = $name . ': ' . $value;\n }\n $responseHeaders = [];\n curl_setopt_array($handle, [\n CURLOPT_CUSTOMREQUEST => $request['method'],\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_HTTPHEADER => $headerLines,\n CURLOPT_HEADERFUNCTION => function ($ch, string $line) use (&$responseHeaders): int {\n $parts = explode(':', $line, 2);\n if (count($parts) === 2) {\n $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);\n }\n return strlen($line);\n },\n ]);\n if (($request['body'] ?? null) !== null) {\n curl_setopt($handle, CURLOPT_POSTFIELDS, $request['body']);\n }\n if ($config->timeout !== null) {\n curl_setopt($handle, CURLOPT_TIMEOUT_MS, (int) round($config->timeout * 1000));\n }\n $body = curl_exec($handle);\n $errno = curl_errno($handle);\n $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);\n $effectiveUrl = (string) curl_getinfo($handle, CURLINFO_EFFECTIVE_URL);\n if ($errno !== 0) {\n $timedOut = $errno === CURLE_OPERATION_TIMEDOUT || $errno === CURLE_COULDNT_CONNECT;\n return [\n 'status' => 0,\n 'reason' => curl_strerror($errno) ?? 'transport error',\n 'headers' => [],\n 'body' => '',\n 'url' => $effectiveUrl,\n 'timedOut' => $timedOut,\n ];\n }\n return [\n 'status' => $status,\n 'reason' => '',\n 'headers' => $responseHeaders,\n 'body' => is_string($body) ? $body : '',\n 'url' => $effectiveUrl,\n 'timedOut' => false,\n ];\n}\n\n/**\n * Send with retries and middleware. `$request` carries `operationId`, `method`, `url`,\n * `headers`, `query`, and optional `body`/`contentType`/`idempotencyKey`.\n * Returns the raw response array; callers map status >= 400 to `ApiError`.\n */\nfunction send(Config $config, array $request): array\n{\n $headers = $request['headers'] ?? [];\n $headers['X-Redocly-Client'] = $config->clientHeader;\n if (($request['contentType'] ?? null) !== null) {\n $headers['Content-Type'] = $request['contentType'];\n }\n if (($request['idempotencyKey'] ?? null) !== null) {\n $headers['Idempotency-Key'] = $request['idempotencyKey'];\n }\n $request['headers'] = $headers;\n\n $handler = fn (array $req): array => rawSend($config, $req);\n foreach (array_reverse($config->middleware) as $middleware) {\n $next = $handler;\n $handler = fn (array $req): array => $middleware($req, $next);\n }\n\n $attempts = max(1, (int) ($config->retry['attempts'] ?? 3));\n $retryOn = $config->retry['retryOn'] ?? __NAMESPACE__ . '\\\\defaultRetryOn';\n $response = null;\n for ($attempt = 1; $attempt <= $attempts; $attempt++) {\n $response = $handler($request);\n $context = [\n 'status' => $response['status'],\n 'timedOut' => $response['timedOut'],\n 'attempt' => $attempt,\n 'operationId' => $request['operationId'] ?? '',\n ];\n if ($attempt === $attempts || !$retryOn($context)) {\n break;\n }\n $seconds = retryDelay($attempt, $config->retry, $response['headers']['retry-after'] ?? null);\n usleep((int) round($seconds * 1_000_000));\n }\n if ($response['timedOut']) {\n throw new TimeoutError($response['url'], $config->timeout, $attempts);\n }\n if ($response['status'] === 0) {\n throw new \\RuntimeException(\"Request to {$response['url']} failed: {$response['reason']}\");\n }\n return $response;\n}\n\n/** Decoded JSON body (assoc arrays), or null for empty bodies. */\nfunction decodeJson(array $response): mixed\n{\n if ($response['body'] === '') {\n return null;\n }\n return json_decode($response['body'], true);\n}\n\n/** `ApiError` from a non-2xx response. */\nfunction apiErrorFrom(array $response): ApiError\n{\n return new ApiError($response['url'], $response['status'], $response['reason'], decodeJson($response));\n}\n\n/** Walk an RFC 6901 JSON pointer over decoded JSON; null on any miss. */\nfunction resolvePointer(mixed $data, string $pointer): mixed\n{\n if ($pointer === '') {\n return $data;\n }\n foreach (explode('/', substr($pointer, 1)) as $token) {\n $key = str_replace(['~1', '~0'], ['/', '~'], $token);\n if (!is_array($data) || !array_key_exists($key, $data)) {\n return null;\n }\n $data = $data[$key];\n }\n return $data;\n}\n\n/** The `rel=\"next\"` target of a `Link` header, or null. */\nfunction linkNext(?string $header): ?string\n{\n if ($header === null) {\n return null;\n }\n foreach (explode(',', $header) as $part) {\n if (preg_match('/<([^>]+)>\\s*;[^,]*rel=\"?next\"?/', trim($part), $match) === 1) {\n return $match[1];\n }\n }\n return null;\n}\n\n/**\n * Auto-pagination: `$call(array $params): [mixed rawPage, array $response]`, `$spec` is the\n * normalized rule (`style`, `param`, `nextCursor`, `hasMore`, `items`), `$base` the caller's\n * query params. Yields raw decoded pages; generated wrappers hydrate them into models.\n */\nfunction iterPages(callable $call, array $spec, array $base): \\Generator\n{\n $params = $base;\n $style = $spec['style'];\n $seenCursors = [];\n $seenLinks = [];\n $offset = null;\n $page = null;\n while (true) {\n [$raw, $response] = $call($params);\n yield $raw;\n if ($style === 'cursor') {\n $next = resolvePointer($raw, $spec['nextCursor'] ?? '');\n if (isset($spec['hasMore']) && resolvePointer($raw, $spec['hasMore']) !== true) {\n return;\n }\n if (!is_string($next) || $next === '' || isset($seenCursors[$next])) {\n return;\n }\n $seenCursors[$next] = true;\n $params[$spec['param']] = $next;\n } elseif ($style === 'link') {\n $target = linkNext($response['headers']['link'] ?? null);\n if ($target === null || isset($seenLinks[$target])) {\n return;\n }\n $seenLinks[$target] = true;\n $parsed = parse_url($target);\n $linkParams = [];\n parse_str($parsed['query'] ?? '', $linkParams);\n $params = array_merge($params, $linkParams);\n } else {\n $items = resolvePointer($raw, $spec['items'] ?? '');\n $count = is_array($items) ? count($items) : 0;\n if ($count === 0) {\n return;\n }\n if ($style === 'offset') {\n $offset = ($offset ?? (int) ($base[$spec['param']] ?? 0)) + $count;\n $params[$spec['param']] = $offset;\n } else {\n $page = ($page ?? (int) ($base[$spec['param']] ?? 1)) + 1;\n $params[$spec['param']] = $page;\n }\n }\n }\n}\n\n/** Parse one SSE frame; returns `[?ServerSentEvent, ?string lastEventId, ?int retryMs]`. */\nfunction parseSseFrame(string $frame, bool $jsonData): array\n{\n $event = 'message';\n $dataLines = [];\n $id = null;\n $retry = null;\n foreach (explode(\"\\n\", str_replace(\"\\r\\n\", \"\\n\", $frame)) as $line) {\n if ($line === '' || str_starts_with($line, ':')) {\n continue;\n }\n $colon = strpos($line, ':');\n $field = $colon === false ? $line : substr($line, 0, $colon);\n $value = $colon === false ? '' : ltrim(substr($line, $colon + 1), ' ');\n if ($field === 'event') {\n $event = $value;\n } elseif ($field === 'data') {\n $dataLines[] = $value;\n } elseif ($field === 'id') {\n $id = $value;\n } elseif ($field === 'retry' && ctype_digit($value)) {\n $retry = (int) $value;\n }\n }\n if ($dataLines === [] && $id === null && $retry === null) {\n return [null, null, $retry];\n }\n $data = implode(\"\\n\", $dataLines);\n $decoded = $jsonData && $data !== '' ? json_decode($data, true) : $data;\n return [new ServerSentEvent($event, $decoded, $id, $retry), $id, $retry];\n}\n\n/**\n * Stream server-sent events. `$open(array $extraHeaders): \\CurlHandle` returns a configured\n * (not yet executed) handle; this pump drives it with curl_multi, yields parsed frames, and\n * reconnects with `Last-Event-ID` on transient failures (4xx is definitive; backoff <= 30s).\n */\nfunction iterSse(callable $open, bool $jsonData): \\Generator\n{\n $lastEventId = null;\n $retryMs = 3000;\n while (true) {\n $extra = ['Accept' => 'text/event-stream'];\n if ($lastEventId !== null) {\n $extra['Last-Event-ID'] = $lastEventId;\n }\n $handle = $open($extra);\n $buffer = '';\n curl_setopt($handle, CURLOPT_WRITEFUNCTION, function ($ch, string $chunk) use (&$buffer): int {\n $buffer .= $chunk;\n return strlen($chunk);\n });\n $multi = curl_multi_init();\n curl_multi_add_handle($multi, $handle);\n do {\n curl_multi_exec($multi, $running);\n if ($running > 0) {\n curl_multi_select($multi, 0.1);\n }\n while (($split = strpos($buffer, \"\\n\\n\")) !== false || ($split = strpos($buffer, \"\\r\\n\\r\\n\")) !== false) {\n $frameLength = $buffer[$split] === \"\\r\" ? 4 : 2;\n $frame = substr($buffer, 0, $split);\n $buffer = substr($buffer, $split + $frameLength);\n [$event, $id, $retry] = parseSseFrame($frame, $jsonData);\n if ($id !== null) {\n $lastEventId = $id;\n }\n if ($retry !== null) {\n $retryMs = min($retry, 30000);\n }\n if ($event !== null) {\n yield $event;\n }\n }\n } while ($running > 0);\n $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);\n $url = (string) curl_getinfo($handle, CURLINFO_EFFECTIVE_URL);\n curl_multi_remove_handle($multi, $handle);\n curl_multi_close($multi);\n if ($status >= 400 && $status < 500) {\n throw new ApiError($url, $status, '', $buffer);\n }\n // A clean 200 end-of-stream is done; anything else reconnects with Last-Event-ID.\n if ($status === 200) {\n return;\n }\n usleep($retryMs * 1000);\n }\n}\n\n/** Encode an assoc body as `multipart/form-data`; nested values are JSON parts. Returns `[contentType, body]`. */\nfunction toMultipart(array $body): array\n{\n $boundary = 'redocly-' . bin2hex(random_bytes(12));\n $parts = '';\n foreach ($body as $name => $value) {\n $parts .= \"--{$boundary}\\r\\n\";\n if (is_array($value)) {\n $parts .= \"Content-Disposition: form-data; name=\\\"{$name}\\\"\\r\\n\";\n $parts .= \"Content-Type: application/json\\r\\n\\r\\n\";\n $parts .= json_encode($value) . \"\\r\\n\";\n } else {\n $parts .= \"Content-Disposition: form-data; name=\\\"{$name}\\\"\\r\\n\\r\\n\";\n $parts .= (is_bool($value) ? ($value ? 'true' : 'false') : (string) $value) . \"\\r\\n\";\n }\n }\n $parts .= \"--{$boundary}--\\r\\n\";\n return ['multipart/form-data; boundary=' . $boundary, $parts];\n}\n"; + "= 8.1, zero Composer dependencies; HTTP over the curl extension.\n// The generated file re-declares the namespace; the embed strips this header.\n\ndeclare(strict_types=1);\n\nnamespace RedoclyClientRuntime;\n\n/** A response with status >= 400, decoded body attached. */\nfinal class ApiError extends \\RuntimeException\n{\n public function __construct(\n public readonly string $url,\n public readonly int $status,\n public readonly string $reason,\n public readonly mixed $body,\n ) {\n parent::__construct(\"HTTP {$status} {$reason} for {$url}\");\n }\n}\n\n/** Every attempt timed out or failed to connect. */\nfinal class TimeoutError extends \\RuntimeException\n{\n public function __construct(\n public readonly string $url,\n public readonly ?float $timeout,\n public readonly int $attempts,\n ) {\n $seconds = $timeout === null ? 'the configured timeout' : \"{$timeout}s\";\n parent::__construct(\"Request to {$url} timed out after {$seconds} ({$attempts} attempt(s))\");\n }\n}\n\n/** One parsed `text/event-stream` frame. */\n/** A `WithHeaders()` result: the decoded body plus coerced declared headers. */\nfinal class Envelope\n{\n public function __construct(\n public readonly mixed $data,\n public readonly array $headers,\n public readonly int $status,\n ) {\n }\n}\n\n/** Coerce declared response headers per `[name, key, type]` specs; absent/unparsable omitted. */\nfunction readEnvelopeHeaders(array $response, array $specs): array\n{\n $headers = [];\n foreach ($specs as [$name, $key, $type]) {\n $raw = $response['headers'][$name] ?? null;\n if ($raw === null) {\n continue;\n }\n if ($type === 'integer' || $type === 'number') {\n if (is_numeric($raw)) {\n $headers[$key] = $type === 'integer' ? (int) $raw : (float) $raw;\n }\n } elseif ($type === 'boolean') {\n $lower = strtolower(trim($raw));\n if ($lower === 'true' || $lower === 'false') {\n $headers[$key] = $lower === 'true';\n }\n } else {\n $headers[$key] = $raw;\n }\n }\n return $headers;\n}\n\nfinal class ServerSentEvent\n{\n public function __construct(\n public readonly string $event,\n public readonly mixed $data,\n public readonly ?string $id = null,\n public readonly ?int $retry = null,\n ) {\n }\n}\n\n/**\n * Per-instance configuration.\n * `auth`: `['bearer' => string|callable, 'basic' => ['username' => ..., 'password' => ...], 'apiKey' => [scheme => string|callable]]`.\n * `retry`: `['attempts' => int, 'delay' => float, 'strategy' => 'exponential'|'fixed', 'retryOn' => callable]`.\n * `middleware`: callables `fn(array $request, callable $next): array` around each attempt.\n */\nfinal class Config\n{\n public function __construct(\n public string $serverUrl = '',\n public array $auth = [],\n public ?float $timeout = null,\n public array $retry = [],\n public array $middleware = [],\n public string $clientHeader = 'redocly-client-generator',\n ) {\n }\n}\n\n/** Resolve a literal-or-callable credential to its string value. */\nfunction resolveToken(mixed $provider): string\n{\n return is_callable($provider) ? (string) $provider() : (string) $provider;\n}\n\n/**\n * Apply the first fully-configured security alternative. `$security` is an OR-list\n * of AND-sets of specs: `['kind' => 'bearer'|'basic'|'apiKey', 'scheme' => ..., 'name' => ?, 'in' => ?]`.\n * Returns `[headers, query, cookies]`.\n */\nfunction resolveAuth(array $security, array $auth): array\n{\n foreach ($security as $andSet) {\n $headers = [];\n $query = [];\n $cookies = [];\n $satisfied = true;\n foreach ($andSet as $spec) {\n if ($spec['kind'] === 'bearer' && isset($auth['bearer'])) {\n $headers['Authorization'] = 'Bearer ' . resolveToken($auth['bearer']);\n } elseif ($spec['kind'] === 'basic' && isset($auth['basic'])) {\n $headers['Authorization'] =\n 'Basic ' . base64_encode($auth['basic']['username'] . ':' . $auth['basic']['password']);\n } elseif ($spec['kind'] === 'apiKey' && isset($auth['apiKey'][$spec['scheme']])) {\n $value = resolveToken($auth['apiKey'][$spec['scheme']]);\n if ($spec['in'] === 'query') {\n $query[$spec['name']] = $value;\n } elseif ($spec['in'] === 'cookie') {\n $cookies[] = $spec['name'] . '=' . rawurlencode($value);\n } else {\n $headers[$spec['name']] = $value;\n }\n } else {\n $satisfied = false;\n break;\n }\n }\n if ($satisfied) {\n return [$headers, $query, $cookies];\n }\n }\n return [[], [], []];\n}\n\n/** Substitute `{param}` templates with encoded values and prefix the server URL. */\nfunction buildUrl(string $serverUrl, string $path, array $pathParams): string\n{\n foreach ($pathParams as $name => $value) {\n $path = str_replace('{' . $name . '}', rawurlencode((string) $value), $path);\n }\n return rtrim($serverUrl, '/') . $path;\n}\n\n/** The default retry predicate: 5xx, 429, and transport timeouts/connect failures. */\nfunction defaultRetryOn(array $context): bool\n{\n if (($context['timedOut'] ?? false) === true) {\n return true;\n }\n $status = $context['status'] ?? 0;\n return $status >= 500 || $status === 429;\n}\n\n/** Delay before the next attempt: `Retry-After` wins; otherwise jittered (fixed|exponential) backoff. */\nfunction retryDelay(int $attempt, array $retry, ?string $retryAfter): float\n{\n if ($retryAfter !== null && ctype_digit($retryAfter)) {\n return (float) $retryAfter;\n }\n $base = (float) ($retry['delay'] ?? 1.0);\n $strategy = $retry['strategy'] ?? 'exponential';\n $delay = $strategy === 'fixed' ? $base : $base * (2 ** ($attempt - 1));\n return $delay * (0.5 + mt_rand() / mt_getrandmax() / 2);\n}\n\n/** Append query params in form style: list values repeat the key (`tag=a&tag=b`). */\nfunction appendQuery(string $url, array $query): string\n{\n $pairs = [];\n foreach ($query as $name => $value) {\n foreach (is_array($value) ? $value : [$value] as $single) {\n $encoded = is_bool($single) ? ($single ? 'true' : 'false') : (string) $single;\n $pairs[] = rawurlencode($name) . '=' . rawurlencode($encoded);\n }\n }\n if ($pairs === []) {\n return $url;\n }\n return $url . (str_contains($url, '?') ? '&' : '?') . implode('&', $pairs);\n}\n\n/** One raw curl exchange. Returns `['status', 'reason', 'headers', 'body', 'url', 'timedOut']`. */\nfunction rawSend(Config $config, array $request): array\n{\n $url = appendQuery($request['url'], $request['query'] ?? []);\n $handle = curl_init($url);\n $headerLines = [];\n foreach ($request['headers'] ?? [] as $name => $value) {\n $headerLines[] = $name . ': ' . $value;\n }\n $responseHeaders = [];\n curl_setopt_array($handle, [\n CURLOPT_CUSTOMREQUEST => $request['method'],\n CURLOPT_RETURNTRANSFER => true,\n CURLOPT_HTTPHEADER => $headerLines,\n CURLOPT_HEADERFUNCTION => function ($ch, string $line) use (&$responseHeaders): int {\n $parts = explode(':', $line, 2);\n if (count($parts) === 2) {\n $responseHeaders[strtolower(trim($parts[0]))] = trim($parts[1]);\n }\n return strlen($line);\n },\n ]);\n if (($request['body'] ?? null) !== null) {\n curl_setopt($handle, CURLOPT_POSTFIELDS, $request['body']);\n }\n if ($config->timeout !== null) {\n curl_setopt($handle, CURLOPT_TIMEOUT_MS, (int) round($config->timeout * 1000));\n }\n $body = curl_exec($handle);\n $errno = curl_errno($handle);\n $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);\n $effectiveUrl = (string) curl_getinfo($handle, CURLINFO_EFFECTIVE_URL);\n if ($errno !== 0) {\n $timedOut = $errno === CURLE_OPERATION_TIMEDOUT || $errno === CURLE_COULDNT_CONNECT;\n return [\n 'status' => 0,\n 'reason' => curl_strerror($errno) ?? 'transport error',\n 'headers' => [],\n 'body' => '',\n 'url' => $effectiveUrl,\n 'timedOut' => $timedOut,\n ];\n }\n return [\n 'status' => $status,\n 'reason' => '',\n 'headers' => $responseHeaders,\n 'body' => is_string($body) ? $body : '',\n 'url' => $effectiveUrl,\n 'timedOut' => false,\n ];\n}\n\n/**\n * Send with retries and middleware. `$request` carries `operationId`, `method`, `url`,\n * `headers`, `query`, and optional `body`/`contentType`/`idempotencyKey`.\n * Returns the raw response array; callers map status >= 400 to `ApiError`.\n */\nfunction send(Config $config, array $request): array\n{\n $headers = $request['headers'] ?? [];\n $headers['X-Redocly-Client'] = $config->clientHeader;\n if (($request['contentType'] ?? null) !== null) {\n $headers['Content-Type'] = $request['contentType'];\n }\n if (($request['idempotencyKey'] ?? null) !== null) {\n $headers['Idempotency-Key'] = $request['idempotencyKey'];\n }\n $request['headers'] = $headers;\n\n $handler = fn (array $req): array => rawSend($config, $req);\n foreach (array_reverse($config->middleware) as $middleware) {\n $next = $handler;\n $handler = fn (array $req): array => $middleware($req, $next);\n }\n\n $attempts = max(1, (int) ($config->retry['attempts'] ?? 3));\n $retryOn = $config->retry['retryOn'] ?? __NAMESPACE__ . '\\\\defaultRetryOn';\n $response = null;\n for ($attempt = 1; $attempt <= $attempts; $attempt++) {\n $response = $handler($request);\n $context = [\n 'status' => $response['status'],\n 'timedOut' => $response['timedOut'],\n 'attempt' => $attempt,\n 'operationId' => $request['operationId'] ?? '',\n ];\n if ($attempt === $attempts || !$retryOn($context)) {\n break;\n }\n $seconds = retryDelay($attempt, $config->retry, $response['headers']['retry-after'] ?? null);\n usleep((int) round($seconds * 1_000_000));\n }\n if ($response['timedOut']) {\n throw new TimeoutError($response['url'], $config->timeout, $attempts);\n }\n if ($response['status'] === 0) {\n throw new \\RuntimeException(\"Request to {$response['url']} failed: {$response['reason']}\");\n }\n return $response;\n}\n\n/** Decoded JSON body (assoc arrays), or null for empty bodies. */\nfunction decodeJson(array $response): mixed\n{\n if ($response['body'] === '') {\n return null;\n }\n return json_decode($response['body'], true);\n}\n\n/** `ApiError` from a non-2xx response. */\nfunction apiErrorFrom(array $response): ApiError\n{\n return new ApiError($response['url'], $response['status'], $response['reason'], decodeJson($response));\n}\n\n/** Walk an RFC 6901 JSON pointer over decoded JSON; null on any miss. */\nfunction resolvePointer(mixed $data, string $pointer): mixed\n{\n if ($pointer === '') {\n return $data;\n }\n foreach (explode('/', substr($pointer, 1)) as $token) {\n $key = str_replace(['~1', '~0'], ['/', '~'], $token);\n if (!is_array($data) || !array_key_exists($key, $data)) {\n return null;\n }\n $data = $data[$key];\n }\n return $data;\n}\n\n/** The `rel=\"next\"` target of a `Link` header, or null. */\nfunction linkNext(?string $header): ?string\n{\n if ($header === null) {\n return null;\n }\n foreach (explode(',', $header) as $part) {\n if (preg_match('/<([^>]+)>\\s*;[^,]*rel=\"?next\"?/', trim($part), $match) === 1) {\n return $match[1];\n }\n }\n return null;\n}\n\n/**\n * Auto-pagination: `$call(array $params): [mixed rawPage, array $response]`, `$spec` is the\n * normalized rule (`style`, `param`, `nextCursor`, `hasMore`, `items`), `$base` the caller's\n * query params. Yields raw decoded pages; generated wrappers hydrate them into models.\n */\nfunction iterPages(callable $call, array $spec, array $base): \\Generator\n{\n $params = $base;\n $style = $spec['style'];\n $seenCursors = [];\n $seenLinks = [];\n $offset = null;\n $page = null;\n while (true) {\n [$raw, $response] = $call($params);\n yield $raw;\n if ($style === 'cursor') {\n $next = resolvePointer($raw, $spec['nextCursor'] ?? '');\n if (isset($spec['hasMore']) && resolvePointer($raw, $spec['hasMore']) !== true) {\n return;\n }\n if (!is_string($next) || $next === '' || isset($seenCursors[$next])) {\n return;\n }\n $seenCursors[$next] = true;\n $params[$spec['param']] = $next;\n } elseif ($style === 'link') {\n $target = linkNext($response['headers']['link'] ?? null);\n if ($target === null || isset($seenLinks[$target])) {\n return;\n }\n $seenLinks[$target] = true;\n $parsed = parse_url($target);\n $linkParams = [];\n parse_str($parsed['query'] ?? '', $linkParams);\n $params = array_merge($params, $linkParams);\n } else {\n $items = resolvePointer($raw, $spec['items'] ?? '');\n $count = is_array($items) ? count($items) : 0;\n if ($count === 0) {\n return;\n }\n if ($style === 'offset') {\n $offset = ($offset ?? (int) ($base[$spec['param']] ?? 0)) + $count;\n $params[$spec['param']] = $offset;\n } else {\n $page = ($page ?? (int) ($base[$spec['param']] ?? 1)) + 1;\n $params[$spec['param']] = $page;\n }\n }\n }\n}\n\n/** Parse one SSE frame; returns `[?ServerSentEvent, ?string lastEventId, ?int retryMs]`. */\nfunction parseSseFrame(string $frame, bool $jsonData): array\n{\n $event = 'message';\n $dataLines = [];\n $id = null;\n $retry = null;\n foreach (explode(\"\\n\", str_replace(\"\\r\\n\", \"\\n\", $frame)) as $line) {\n if ($line === '' || str_starts_with($line, ':')) {\n continue;\n }\n $colon = strpos($line, ':');\n $field = $colon === false ? $line : substr($line, 0, $colon);\n $value = $colon === false ? '' : ltrim(substr($line, $colon + 1), ' ');\n if ($field === 'event') {\n $event = $value;\n } elseif ($field === 'data') {\n $dataLines[] = $value;\n } elseif ($field === 'id') {\n $id = $value;\n } elseif ($field === 'retry' && ctype_digit($value)) {\n $retry = (int) $value;\n }\n }\n if ($dataLines === [] && $id === null && $retry === null) {\n return [null, null, $retry];\n }\n $data = implode(\"\\n\", $dataLines);\n $decoded = $jsonData && $data !== '' ? json_decode($data, true) : $data;\n return [new ServerSentEvent($event, $decoded, $id, $retry), $id, $retry];\n}\n\n/**\n * Stream server-sent events. `$open(array $extraHeaders): \\CurlHandle` returns a configured\n * (not yet executed) handle; this pump drives it with curl_multi, yields parsed frames, and\n * reconnects with `Last-Event-ID` on transient failures (4xx is definitive; backoff <= 30s).\n */\nfunction iterSse(callable $open, bool $jsonData): \\Generator\n{\n $lastEventId = null;\n $retryMs = 3000;\n while (true) {\n $extra = ['Accept' => 'text/event-stream'];\n if ($lastEventId !== null) {\n $extra['Last-Event-ID'] = $lastEventId;\n }\n $handle = $open($extra);\n $buffer = '';\n curl_setopt($handle, CURLOPT_WRITEFUNCTION, function ($ch, string $chunk) use (&$buffer): int {\n $buffer .= $chunk;\n return strlen($chunk);\n });\n $multi = curl_multi_init();\n curl_multi_add_handle($multi, $handle);\n do {\n curl_multi_exec($multi, $running);\n if ($running > 0) {\n curl_multi_select($multi, 0.1);\n }\n while (($split = strpos($buffer, \"\\n\\n\")) !== false || ($split = strpos($buffer, \"\\r\\n\\r\\n\")) !== false) {\n $frameLength = $buffer[$split] === \"\\r\" ? 4 : 2;\n $frame = substr($buffer, 0, $split);\n $buffer = substr($buffer, $split + $frameLength);\n [$event, $id, $retry] = parseSseFrame($frame, $jsonData);\n if ($id !== null) {\n $lastEventId = $id;\n }\n if ($retry !== null) {\n $retryMs = min($retry, 30000);\n }\n if ($event !== null) {\n yield $event;\n }\n }\n } while ($running > 0);\n $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE);\n $url = (string) curl_getinfo($handle, CURLINFO_EFFECTIVE_URL);\n curl_multi_remove_handle($multi, $handle);\n curl_multi_close($multi);\n if ($status >= 400 && $status < 500) {\n throw new ApiError($url, $status, '', $buffer);\n }\n // A clean 200 end-of-stream is done; anything else reconnects with Last-Event-ID.\n if ($status === 200) {\n return;\n }\n usleep($retryMs * 1000);\n }\n}\n\n/** Encode an assoc body as `multipart/form-data`; nested values are JSON parts. Returns `[contentType, body]`. */\nfunction toMultipart(array $body): array\n{\n $boundary = 'redocly-' . bin2hex(random_bytes(12));\n $parts = '';\n foreach ($body as $name => $value) {\n $parts .= \"--{$boundary}\\r\\n\";\n if (is_array($value)) {\n $parts .= \"Content-Disposition: form-data; name=\\\"{$name}\\\"\\r\\n\";\n $parts .= \"Content-Type: application/json\\r\\n\\r\\n\";\n $parts .= json_encode($value) . \"\\r\\n\";\n } else {\n $parts .= \"Content-Disposition: form-data; name=\\\"{$name}\\\"\\r\\n\\r\\n\";\n $parts .= (is_bool($value) ? ($value ? 'true' : 'false') : (string) $value) . \"\\r\\n\";\n }\n }\n $parts .= \"--{$boundary}--\\r\\n\";\n return ['multipart/form-data; boundary=' . $boundary, $parts];\n}\n"; diff --git a/packages/client-generator/src/emitters/python-runtime-sources.ts b/packages/client-generator/src/emitters/python-runtime-sources.ts index c9fd021ef2..2135d3ff1d 100644 --- a/packages/client-generator/src/emitters/python-runtime-sources.ts +++ b/packages/client-generator/src/emitters/python-runtime-sources.ts @@ -9,7 +9,7 @@ export const PYTHON_RUNTIME_SOURCES = { '_decode.py': '# Reflective JSON <-> dataclass conversion for generated Python clients. The\n# generated models are plain dataclasses; this decoder hydrates parsed JSON into\n# them (and encode() mirrors back to wire shape), honoring each class\'s\n# `_field_map` (python name -> wire name) and typing constructs the generator\n# emits: Optional/Union, List, Dict, Enum, Literal, Any.\nfrom __future__ import annotations\n\nimport dataclasses\nimport typing\nfrom enum import Enum\nfrom typing import Any, Dict, Tuple, get_args, get_origin, get_type_hints\n\n# Discriminated unions: resolved Union annotation -> (wire property, {value: class}).\n# The generated module registers its unions here; decode() dispatches through it\n# before falling back to trying members in order.\nDISCRIMINATORS: Dict[Any, Tuple[str, Dict[str, Any]]] = {}\n\n\ndef decode(type_: Any, data: Any):\n """Best-effort hydration: wire data -> the annotated Python shape. Unknown or\n mismatched shapes pass through unchanged (the server is the source of truth)."""\n if data is None or type_ is Any or type_ is None:\n return data\n origin = get_origin(type_)\n if origin is typing.Union:\n discriminator = DISCRIMINATORS.get(type_)\n if discriminator is not None and isinstance(data, dict):\n wire_property, mapping = discriminator\n target = mapping.get(data.get(wire_property))\n if target is not None:\n try:\n return decode(target, data)\n except (TypeError, ValueError, KeyError):\n pass\n for member in get_args(type_):\n if member is type(None):\n continue\n try:\n return decode(member, data)\n except (TypeError, ValueError, KeyError):\n continue\n return data\n if origin is list:\n (item_type,) = get_args(type_) or (Any,)\n return [decode(item_type, item) for item in data]\n if origin is dict:\n args = get_args(type_)\n value_type = args[1] if len(args) == 2 else Any\n return {key: decode(value_type, value) for key, value in data.items()}\n if origin is typing.Literal:\n return data\n if isinstance(type_, type) and issubclass(type_, Enum):\n return type_(data)\n if dataclasses.is_dataclass(type_):\n hints = get_type_hints(type_)\n field_map = getattr(type_, "_field_map", {})\n kwargs = {}\n for field in dataclasses.fields(type_):\n wire = field_map.get(field.name, field.name)\n if isinstance(data, dict) and wire in data:\n kwargs[field.name] = decode(hints.get(field.name, Any), data[wire])\n return type_(**kwargs)\n return data\n\n\ndef encode(value: Any):\n """Python shape -> wire (JSON) shape; inverse of decode for request bodies."""\n if dataclasses.is_dataclass(value) and not isinstance(value, type):\n field_map = getattr(type(value), "_field_map", {})\n out = {}\n for field in dataclasses.fields(value):\n item = getattr(value, field.name)\n if item is None:\n continue\n out[field_map.get(field.name, field.name)] = encode(item)\n return out\n if isinstance(value, Enum):\n return value.value\n if isinstance(value, list):\n return [encode(item) for item in value]\n if isinstance(value, dict):\n return {key: encode(item) for key, item in value.items()}\n return value\n', '_send.py': - '# The request core for generated Python clients — mirror of the TypeScript\n# runtime\'s send.ts: default + config + per-call headers, on_request middleware\n# BEFORE serialization (mutations are sent), the retry loop (idempotent-methods\n# default, Idempotency-Key opt-in makes POST/PATCH safe, Retry-After honored,\n# exponential backoff with full jitter, a fresh timeout budget per attempt), and\n# the reverse on_response onion.\nfrom __future__ import annotations\n\nimport asyncio\nimport random\nimport time\nimport uuid\nfrom typing import Any, Dict, List, Optional\n\nimport httpx\n\nfrom ._errors import ApiTimeoutError\n\n_IDEMPOTENT_METHODS = {"GET", "HEAD", "PUT", "DELETE", "OPTIONS"}\n_TRANSIENT_STATUS = {408, 429, 500, 502, 503, 504}\n\n\ndef _default_retry_on(method: str, headers: Dict[str, str], response: Optional[httpx.Response]) -> bool:\n safe = method.upper() in _IDEMPOTENT_METHODS or "Idempotency-Key" in headers\n if not safe:\n return False\n return response is None or response.status_code in _TRANSIENT_STATUS\n\n\ndef _retry_delay(retry: Dict[str, Any], attempt: int, retry_after: Optional[str]) -> float:\n if retry_after:\n try:\n return float(retry_after)\n except ValueError:\n pass # HTTP-date form: fall through to backoff\n base = float(retry.get("retry_delay", 1.0))\n raw = base if retry.get("retry_strategy") == "fixed" else base * (2 ** (attempt - 1))\n return random.uniform(0, raw) if retry.get("jitter", True) is not False else raw\n\n\ndef send(\n client: httpx.Client,\n config: Dict[str, Any],\n op: Dict[str, Any],\n url: str,\n *,\n method: str,\n headers: Optional[Dict[str, str]] = None,\n params: Optional[Dict[str, Any]] = None,\n json_body: Any = None,\n content: Any = None,\n data: Any = None,\n files: Any = None,\n timeout: Optional[float] = None,\n idempotency_key: Any = None,\n retry: Optional[Dict[str, Any]] = None,\n) -> httpx.Response:\n merged_retry: Dict[str, Any] = {**(config.get("retry") or {}), **(retry or {})}\n effective_timeout = timeout if timeout is not None else config.get("timeout")\n merged_headers: Dict[str, str] = {**(config.get("headers") or {}), **(headers or {})}\n\n # One stable key per LOGICAL call — set before the retry loop so every\n # attempt re-sends the same key; a caller-provided header always wins.\n key = idempotency_key if idempotency_key is not None else config.get("idempotency_key")\n if (\n key not in (None, False)\n and method.upper() in ("POST", "PATCH")\n and "Idempotency-Key" not in merged_headers\n ):\n merged_headers["Idempotency-Key"] = (\n key if isinstance(key, str) else key() if callable(key) else str(uuid.uuid4())\n )\n\n context = {\n "url": url,\n "method": method.upper(),\n "headers": merged_headers,\n "body": json_body,\n "operation": op,\n }\n middleware: List[Any] = config.get("middleware") or []\n for mw in middleware:\n on_request = getattr(mw, "on_request", None) or (mw.get("on_request") if isinstance(mw, dict) else None)\n if on_request:\n on_request(context)\n\n max_attempts = 1 + int(merged_retry.get("retries", 0))\n retry_on = merged_retry.get("retry_on") or (\n lambda ctx: _default_retry_on(context["method"], context["headers"], ctx.get("response"))\n )\n\n attempt = 0\n while True:\n attempt += 1\n try:\n response = client.request(\n context["method"],\n context["url"],\n headers=context["headers"],\n params=params,\n json=context["body"] if content is None and files is None and data is None else None,\n content=content,\n data=data,\n files=files,\n timeout=effective_timeout if effective_timeout is not None else httpx.USE_CLIENT_DEFAULT,\n )\n except httpx.TimeoutException:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n time.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise ApiTimeoutError(op.get("id", "?"), float(effective_timeout or 0), attempt) from None\n except httpx.TransportError:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n time.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise\n\n for mw in reversed(middleware):\n on_response = getattr(mw, "on_response", None) or (mw.get("on_response") if isinstance(mw, dict) else None)\n if on_response:\n replaced = on_response(response, context)\n if replaced is not None:\n response = replaced\n\n if (\n not response.is_success\n and attempt < max_attempts\n and retry_on({"attempt": attempt, "response": response})\n ):\n time.sleep(_retry_delay(merged_retry, attempt, response.headers.get("retry-after")))\n continue\n return response\n\n\nasync def send_async(\n client: httpx.AsyncClient,\n config: Dict[str, Any],\n op: Dict[str, Any],\n url: str,\n *,\n method: str,\n headers: Optional[Dict[str, str]] = None,\n params: Optional[Dict[str, Any]] = None,\n json_body: Any = None,\n content: Any = None,\n data: Any = None,\n files: Any = None,\n timeout: Optional[float] = None,\n idempotency_key: Any = None,\n retry: Optional[Dict[str, Any]] = None,\n) -> httpx.Response:\n """The async mirror of send() — same retry/timeout/idempotency semantics."""\n merged_retry: Dict[str, Any] = {**(config.get("retry") or {}), **(retry or {})}\n effective_timeout = timeout if timeout is not None else config.get("timeout")\n merged_headers: Dict[str, str] = {**(config.get("headers") or {}), **(headers or {})}\n key = idempotency_key if idempotency_key is not None else config.get("idempotency_key")\n if (\n key not in (None, False)\n and method.upper() in ("POST", "PATCH")\n and "Idempotency-Key" not in merged_headers\n ):\n merged_headers["Idempotency-Key"] = (\n key if isinstance(key, str) else key() if callable(key) else str(uuid.uuid4())\n )\n context = {\n "url": url,\n "method": method.upper(),\n "headers": merged_headers,\n "body": json_body,\n "operation": op,\n }\n middleware: List[Any] = config.get("middleware") or []\n for mw in middleware:\n on_request = getattr(mw, "on_request", None) or (mw.get("on_request") if isinstance(mw, dict) else None)\n if on_request:\n on_request(context)\n max_attempts = 1 + int(merged_retry.get("retries", 0))\n retry_on = merged_retry.get("retry_on") or (\n lambda ctx: _default_retry_on(context["method"], context["headers"], ctx.get("response"))\n )\n attempt = 0\n while True:\n attempt += 1\n try:\n response = await client.request(\n context["method"],\n context["url"],\n headers=context["headers"],\n params=params,\n json=context["body"] if content is None and files is None and data is None else None,\n content=content,\n data=data,\n files=files,\n timeout=effective_timeout if effective_timeout is not None else httpx.USE_CLIENT_DEFAULT,\n )\n except httpx.TimeoutException:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n await asyncio.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise ApiTimeoutError(op.get("id", "?"), float(effective_timeout or 0), attempt) from None\n except httpx.TransportError:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n await asyncio.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise\n for mw in reversed(middleware):\n on_response = getattr(mw, "on_response", None) or (mw.get("on_response") if isinstance(mw, dict) else None)\n if on_response:\n replaced = on_response(response, context)\n if replaced is not None:\n response = replaced\n if (\n not response.is_success\n and attempt < max_attempts\n and retry_on({"attempt": attempt, "response": response})\n ):\n await asyncio.sleep(_retry_delay(merged_retry, attempt, response.headers.get("retry-after")))\n continue\n return response\n', + '# The request core for generated Python clients — mirror of the TypeScript\n# runtime\'s send.ts: default + config + per-call headers, on_request middleware\n# BEFORE serialization (mutations are sent), the retry loop (idempotent-methods\n# default, Idempotency-Key opt-in makes POST/PATCH safe, Retry-After honored,\n# exponential backoff with full jitter, a fresh timeout budget per attempt), and\n# the reverse on_response onion.\nfrom __future__ import annotations\n\nimport asyncio\nimport random\nimport time\nimport uuid\nfrom dataclasses import dataclass\nfrom typing import Any, Dict, Generic, List, Optional, Tuple, TypeVar\n\nimport httpx\n\nfrom ._errors import ApiTimeoutError\n\nT = TypeVar("T")\n\n\n@dataclass\nclass Envelope(Generic[T]):\n """A *_with_headers() result: decoded body + coerced declared headers + raw response."""\n\n data: T\n headers: Dict[str, Any]\n response: httpx.Response\n\n\ndef read_envelope_headers(\n response: httpx.Response, specs: List[Tuple[str, str, str]]\n) -> Dict[str, Any]:\n """Coerce declared response headers per (name, key, type) specs; absent/unparsable omitted."""\n headers: Dict[str, Any] = {}\n for name, key, type_ in specs:\n raw = response.headers.get(name)\n if raw is None:\n continue\n if type_ in ("integer", "number"):\n try:\n headers[key] = int(raw) if type_ == "integer" else float(raw)\n except ValueError:\n pass\n elif type_ == "boolean":\n lower = raw.strip().lower()\n if lower in ("true", "false"):\n headers[key] = lower == "true"\n else:\n headers[key] = raw\n return headers\n\n\n_IDEMPOTENT_METHODS = {"GET", "HEAD", "PUT", "DELETE", "OPTIONS"}\n_TRANSIENT_STATUS = {408, 429, 500, 502, 503, 504}\n\n\ndef _default_retry_on(method: str, headers: Dict[str, str], response: Optional[httpx.Response]) -> bool:\n safe = method.upper() in _IDEMPOTENT_METHODS or "Idempotency-Key" in headers\n if not safe:\n return False\n return response is None or response.status_code in _TRANSIENT_STATUS\n\n\ndef _retry_delay(retry: Dict[str, Any], attempt: int, retry_after: Optional[str]) -> float:\n if retry_after:\n try:\n return float(retry_after)\n except ValueError:\n pass # HTTP-date form: fall through to backoff\n base = float(retry.get("retry_delay", 1.0))\n raw = base if retry.get("retry_strategy") == "fixed" else base * (2 ** (attempt - 1))\n return random.uniform(0, raw) if retry.get("jitter", True) is not False else raw\n\n\ndef send(\n client: httpx.Client,\n config: Dict[str, Any],\n op: Dict[str, Any],\n url: str,\n *,\n method: str,\n headers: Optional[Dict[str, str]] = None,\n params: Optional[Dict[str, Any]] = None,\n json_body: Any = None,\n content: Any = None,\n data: Any = None,\n files: Any = None,\n timeout: Optional[float] = None,\n idempotency_key: Any = None,\n retry: Optional[Dict[str, Any]] = None,\n) -> httpx.Response:\n merged_retry: Dict[str, Any] = {**(config.get("retry") or {}), **(retry or {})}\n effective_timeout = timeout if timeout is not None else config.get("timeout")\n merged_headers: Dict[str, str] = {**(config.get("headers") or {}), **(headers or {})}\n\n # One stable key per LOGICAL call — set before the retry loop so every\n # attempt re-sends the same key; a caller-provided header always wins.\n key = idempotency_key if idempotency_key is not None else config.get("idempotency_key")\n if (\n key not in (None, False)\n and method.upper() in ("POST", "PATCH")\n and "Idempotency-Key" not in merged_headers\n ):\n merged_headers["Idempotency-Key"] = (\n key if isinstance(key, str) else key() if callable(key) else str(uuid.uuid4())\n )\n\n context = {\n "url": url,\n "method": method.upper(),\n "headers": merged_headers,\n "body": json_body,\n "operation": op,\n }\n middleware: List[Any] = config.get("middleware") or []\n for mw in middleware:\n on_request = getattr(mw, "on_request", None) or (mw.get("on_request") if isinstance(mw, dict) else None)\n if on_request:\n on_request(context)\n\n max_attempts = 1 + int(merged_retry.get("retries", 0))\n retry_on = merged_retry.get("retry_on") or (\n lambda ctx: _default_retry_on(context["method"], context["headers"], ctx.get("response"))\n )\n\n attempt = 0\n while True:\n attempt += 1\n try:\n response = client.request(\n context["method"],\n context["url"],\n headers=context["headers"],\n params=params,\n json=context["body"] if content is None and files is None and data is None else None,\n content=content,\n data=data,\n files=files,\n timeout=effective_timeout if effective_timeout is not None else httpx.USE_CLIENT_DEFAULT,\n )\n except httpx.TimeoutException:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n time.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise ApiTimeoutError(op.get("id", "?"), float(effective_timeout or 0), attempt) from None\n except httpx.TransportError:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n time.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise\n\n for mw in reversed(middleware):\n on_response = getattr(mw, "on_response", None) or (mw.get("on_response") if isinstance(mw, dict) else None)\n if on_response:\n replaced = on_response(response, context)\n if replaced is not None:\n response = replaced\n\n if (\n not response.is_success\n and attempt < max_attempts\n and retry_on({"attempt": attempt, "response": response})\n ):\n time.sleep(_retry_delay(merged_retry, attempt, response.headers.get("retry-after")))\n continue\n return response\n\n\nasync def send_async(\n client: httpx.AsyncClient,\n config: Dict[str, Any],\n op: Dict[str, Any],\n url: str,\n *,\n method: str,\n headers: Optional[Dict[str, str]] = None,\n params: Optional[Dict[str, Any]] = None,\n json_body: Any = None,\n content: Any = None,\n data: Any = None,\n files: Any = None,\n timeout: Optional[float] = None,\n idempotency_key: Any = None,\n retry: Optional[Dict[str, Any]] = None,\n) -> httpx.Response:\n """The async mirror of send() — same retry/timeout/idempotency semantics."""\n merged_retry: Dict[str, Any] = {**(config.get("retry") or {}), **(retry or {})}\n effective_timeout = timeout if timeout is not None else config.get("timeout")\n merged_headers: Dict[str, str] = {**(config.get("headers") or {}), **(headers or {})}\n key = idempotency_key if idempotency_key is not None else config.get("idempotency_key")\n if (\n key not in (None, False)\n and method.upper() in ("POST", "PATCH")\n and "Idempotency-Key" not in merged_headers\n ):\n merged_headers["Idempotency-Key"] = (\n key if isinstance(key, str) else key() if callable(key) else str(uuid.uuid4())\n )\n context = {\n "url": url,\n "method": method.upper(),\n "headers": merged_headers,\n "body": json_body,\n "operation": op,\n }\n middleware: List[Any] = config.get("middleware") or []\n for mw in middleware:\n on_request = getattr(mw, "on_request", None) or (mw.get("on_request") if isinstance(mw, dict) else None)\n if on_request:\n on_request(context)\n max_attempts = 1 + int(merged_retry.get("retries", 0))\n retry_on = merged_retry.get("retry_on") or (\n lambda ctx: _default_retry_on(context["method"], context["headers"], ctx.get("response"))\n )\n attempt = 0\n while True:\n attempt += 1\n try:\n response = await client.request(\n context["method"],\n context["url"],\n headers=context["headers"],\n params=params,\n json=context["body"] if content is None and files is None and data is None else None,\n content=content,\n data=data,\n files=files,\n timeout=effective_timeout if effective_timeout is not None else httpx.USE_CLIENT_DEFAULT,\n )\n except httpx.TimeoutException:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n await asyncio.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise ApiTimeoutError(op.get("id", "?"), float(effective_timeout or 0), attempt) from None\n except httpx.TransportError:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n await asyncio.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise\n for mw in reversed(middleware):\n on_response = getattr(mw, "on_response", None) or (mw.get("on_response") if isinstance(mw, dict) else None)\n if on_response:\n replaced = on_response(response, context)\n if replaced is not None:\n response = replaced\n if (\n not response.is_success\n and attempt < max_attempts\n and retry_on({"attempt": attempt, "response": response})\n ):\n await asyncio.sleep(_retry_delay(merged_retry, attempt, response.headers.get("retry-after")))\n continue\n return response\n', '_paginate.py': '# Auto-pagination iterators for generated Python clients — the TypeScript\n# runtime\'s paginate.ts semantics ported: cursor (next-cursor pointer, optional\n# has-more flag, repeated-cursor guard), offset/page (advance by count/one,\n# repeated-page guard, null start treated as absent), and link (RFC 8288\n# `Link: rel="next"` following with relative resolution and a loop guard).\nfrom __future__ import annotations\n\nimport re\nfrom typing import Any, AsyncIterator, Awaitable, Callable, Dict, Iterator, Optional, Tuple\nfrom urllib.parse import parse_qsl, urljoin, urlparse\n\n# call(params) -> (parsed_json, httpx.Response)\nPageCall = Callable[[Dict[str, Any]], Tuple[Any, Any]]\n\n\ndef resolve_pointer(data: Any, pointer: str) -> Any:\n """RFC 6901 JSON pointer over parsed JSON; None on any miss."""\n if pointer == "":\n return data\n if not pointer.startswith("/"):\n return None\n current = data\n for token in pointer[1:].split("/"):\n key = token.replace("~1", "/").replace("~0", "~")\n if isinstance(current, dict):\n current = current.get(key)\n elif isinstance(current, list) and key.isdigit():\n index = int(key)\n current = current[index] if index < len(current) else None\n else:\n return None\n if current is None:\n return None\n return current\n\n\ndef iter_pages(call: PageCall, spec: Dict[str, Any], params: Optional[Dict[str, Any]] = None) -> Iterator[Any]:\n """Yield raw page JSON per the pagination spec; every page is yielded before\n the stop condition is evaluated, so the last page always arrives."""\n style = spec["style"]\n base = dict(params or {})\n if style == "cursor":\n cursor = base.get(spec["param"])\n while True:\n page_params = dict(base)\n if cursor is not None:\n page_params[spec["param"]] = cursor\n page, _response = call(page_params)\n yield page\n if spec.get("has_more") is not None and resolve_pointer(page, spec["has_more"]) is False:\n return\n nxt = resolve_pointer(page, spec.get("next_cursor", ""))\n if nxt is None or nxt == "":\n return\n if not isinstance(nxt, (str, int, float)):\n raise ValueError(f"Pagination cursor at {spec[\'next_cursor\']} is not a string or number")\n if nxt == cursor:\n raise ValueError("Pagination did not advance: the operation returned the same cursor twice")\n cursor = nxt\n elif style == "link":\n yield from _iter_pages_by_link(call, base)\n else: # offset / page\n start = base.get(spec["param"])\n fallback = 1 if style == "page" else 0\n try:\n position = fallback if start in (None, "") else int(start)\n except (TypeError, ValueError):\n position = fallback\n previous_items = None\n while True:\n page, _response = call({**base, spec["param"]: position})\n items = resolve_pointer(page, spec.get("items", ""))\n serialized = repr(items) if isinstance(items, list) else None\n if serialized is not None and serialized == previous_items:\n raise ValueError("Pagination did not advance: the operation returned the same page twice")\n yield page\n if not isinstance(items, list) or len(items) == 0:\n return\n previous_items = serialized\n position += 1 if style == "page" else len(items)\n\n\ndef _link_next(header: Optional[str]) -> Optional[str]:\n if not header:\n return None\n for entry in re.split(r",\\s*(?=<)", header):\n match = re.match(r"^\\s*<([^>]*)>(.*)$", entry)\n if not match:\n continue\n rel = re.search(r\';\\s*rel\\s*=\\s*"?([^";]+)"?\', match.group(2), re.IGNORECASE)\n if rel and "next" in rel.group(1).split():\n return match.group(1)\n return None\n\n\ndef _iter_pages_by_link(call: PageCall, base: Dict[str, Any]) -> Iterator[Any]:\n params = dict(base)\n previous = None\n while True:\n page, response = call(params)\n yield page\n target = _link_next(response.headers.get("link"))\n if target is None:\n return\n page_url = str(response.request.url) if response.request is not None else ""\n nxt = urljoin(page_url or "http://relative.invalid", target)\n if nxt in (previous, page_url):\n raise ValueError(\'Pagination did not advance: the Link rel="next" target repeats\')\n previous = nxt\n link_params: Dict[str, Any] = {}\n for key, value in parse_qsl(urlparse(nxt).query):\n if key in link_params:\n existing = link_params[key]\n link_params[key] = [*existing, value] if isinstance(existing, list) else [existing, value]\n else:\n link_params[key] = value\n params = {**base, **link_params}\n\n\ndef iter_items(call: PageCall, spec: Dict[str, Any], params: Optional[Dict[str, Any]] = None) -> Iterator[Any]:\n """Each page\'s `items` pointer, flattened."""\n for page in iter_pages(call, spec, params):\n items = resolve_pointer(page, spec.get("items", ""))\n if isinstance(items, list):\n yield from items\n\n\n# call(params) -> awaitable of (parsed_json, httpx.Response)\nAsyncPageCall = Callable[[Dict[str, Any]], Awaitable[Tuple[Any, Any]]]\n\n\nasync def aiter_pages(\n call: AsyncPageCall, spec: Dict[str, Any], params: Optional[Dict[str, Any]] = None\n) -> AsyncIterator[Any]:\n """Async mirror of iter_pages — same stop conditions and guards."""\n style = spec["style"]\n base = dict(params or {})\n if style == "cursor":\n cursor = base.get(spec["param"])\n while True:\n page_params = dict(base)\n if cursor is not None:\n page_params[spec["param"]] = cursor\n page, _response = await call(page_params)\n yield page\n if spec.get("has_more") is not None and resolve_pointer(page, spec["has_more"]) is False:\n return\n nxt = resolve_pointer(page, spec.get("next_cursor", ""))\n if nxt is None or nxt == "":\n return\n if not isinstance(nxt, (str, int, float)):\n raise ValueError(f"Pagination cursor at {spec[\'next_cursor\']} is not a string or number")\n if nxt == cursor:\n raise ValueError("Pagination did not advance: the operation returned the same cursor twice")\n cursor = nxt\n elif style == "link":\n previous = None\n link_params: Dict[str, Any] = dict(base)\n while True:\n page, response = await call(link_params)\n yield page\n target = _link_next(response.headers.get("link"))\n if target is None:\n return\n page_url = str(response.request.url) if response.request is not None else ""\n nxt = urljoin(page_url or "http://relative.invalid", target)\n if nxt in (previous, page_url):\n raise ValueError(\'Pagination did not advance: the Link rel="next" target repeats\')\n previous = nxt\n merged: Dict[str, Any] = {}\n for key, value in parse_qsl(urlparse(nxt).query):\n if key in merged:\n existing = merged[key]\n merged[key] = [*existing, value] if isinstance(existing, list) else [existing, value]\n else:\n merged[key] = value\n link_params = {**base, **merged}\n else:\n start = base.get(spec["param"])\n fallback = 1 if style == "page" else 0\n try:\n position = fallback if start in (None, "") else int(start)\n except (TypeError, ValueError):\n position = fallback\n previous_items = None\n while True:\n page, _response = await call({**base, spec["param"]: position})\n items = resolve_pointer(page, spec.get("items", ""))\n serialized = repr(items) if isinstance(items, list) else None\n if serialized is not None and serialized == previous_items:\n raise ValueError("Pagination did not advance: the operation returned the same page twice")\n yield page\n if not isinstance(items, list) or len(items) == 0:\n return\n previous_items = serialized\n position += 1 if style == "page" else len(items)\n\n\nasync def aiter_items(\n call: AsyncPageCall, spec: Dict[str, Any], params: Optional[Dict[str, Any]] = None\n) -> AsyncIterator[Any]:\n async for page in aiter_pages(call, spec, params):\n items = resolve_pointer(page, spec.get("items", ""))\n if isinstance(items, list):\n for item in items:\n yield item\n', '_sse.py': diff --git a/packages/client-generator/src/emitters/response-headers.ts b/packages/client-generator/src/emitters/response-headers.ts index 828d23152b..c6d7e52618 100644 --- a/packages/client-generator/src/emitters/response-headers.ts +++ b/packages/client-generator/src/emitters/response-headers.ts @@ -1,6 +1,7 @@ // Success-response header helpers: descriptor parse hints + Ops / alias type text // for throw-mode `{ envelope: true }`. +import { headerCoerceType } from '../authoring/index.js'; import type { NamedSchemaModel, ResponseHeaderModel, @@ -19,49 +20,15 @@ type PlannedResponseHeader = ResponseHeaderModel & { /** * Runtime coerce hint from a header schema (complex schemas fall back to string). - * Resolves `$ref` through `schemas`, peels nullable unions and metadata-only - * `allOf` intersections, then maps scalar/literal/enum leaves to number/boolean. + * Delegates to the neutral `headerCoerceType`; JavaScript has one number type, + * so `integer` collapses to `number`. */ export function headerParseType( schema: SchemaModel, - schemas: readonly NamedSchemaModel[] = [], - seen: Set = new Set() + schemas: readonly NamedSchemaModel[] = [] ): ResponseHeaderSpec['type'] { - if (schema.kind === 'ref') { - if (seen.has(schema.name)) return 'string'; - seen.add(schema.name); - const named = schemas.find((entry) => entry.name === schema.name); - if (named === undefined) return 'string'; - return headerParseType(named.schema, schemas, seen); - } - if (schema.kind === 'intersection') { - // Drop unknown members (constraint-only allOf branches) and unwrap a sole remainder. - const members = schema.members.filter((member) => member.kind !== 'unknown'); - if (members.length === 1) return headerParseType(members[0], schemas, seen); - const types = [ - ...new Set(members.map((member) => headerParseType(member, schemas, new Set(seen)))), - ]; - return types.length === 1 ? types[0] : 'string'; - } - // Nullable wrappers (`boolean | null`, OpenAPI 3.0 `nullable`) unwrap to the inner type. - if (schema.kind === 'union') { - const members = schema.members.filter((member) => member.kind !== 'null'); - if (members.length === 1) return headerParseType(members[0], schemas, seen); - return 'string'; - } - if (schema.kind === 'scalar') { - if (schema.scalar === 'integer' || schema.scalar === 'number') return 'number'; - if (schema.scalar === 'boolean') return 'boolean'; - } - if (schema.kind === 'literal') { - if (typeof schema.value === 'number') return 'number'; - if (typeof schema.value === 'boolean') return 'boolean'; - } - if (schema.kind === 'enum') { - if (schema.scalar === 'integer' || schema.scalar === 'number') return 'number'; - if (schema.scalar === 'boolean') return 'boolean'; - } - return 'string'; + const coerce = headerCoerceType(schema, { schemas }); + return coerce === 'integer' ? 'number' : coerce; } /** Descriptor `responseHeaders` entries from the success response's declared headers. */ diff --git a/packages/client-generator/src/generators/__tests__/go.test.ts b/packages/client-generator/src/generators/__tests__/go.test.ts index f47bfb5e44..c903148580 100644 --- a/packages/client-generator/src/generators/__tests__/go.test.ts +++ b/packages/client-generator/src/generators/__tests__/go.test.ts @@ -206,6 +206,14 @@ const CAFE: ApiModel = { nextCursor: '/next', items: '/items', }, + successResponseHeaders: [ + { + name: 'pagination-total', + schema: { kind: 'scalar', scalar: 'integer' }, + required: true, + }, + { name: 'link', schema: { kind: 'scalar', scalar: 'string' } }, + ], successResponses: [ { status: '200', @@ -383,6 +391,21 @@ describe('goGenerator parity features', () => { expectGoCompiles(out); }); + it('emits a WithHeaders envelope variant only for ops with declared response headers', () => { + const out = generateGo(); + expect(out).toContain('type ListOrdersHeaders struct {'); + expect(out).toContain('PaginationTotal *int64'); + expect(out).toContain('Link *string'); + expect(out).toContain( + 'func (c *Client) ListOrdersWithHeaders(ctx context.Context, params *ListOrdersParams) (OrderPage, ListOrdersHeaders, error) {' + ); + expect(out).toContain('headers.PaginationTotal = headerInt64(resp.Header, "pagination-total")'); + expect(out).toContain('return out, headers, nil'); + // No declared headers, no variant. + expect(out).not.toContain('GetOrderWithHeaders'); + expectGoCompiles(out); + }); + it('emits one URL function per declared server with variables as parameters', () => { const out = generateGo(); expect(out).toContain('func LiveServerURL(organizationId string) string {'); diff --git a/packages/client-generator/src/generators/__tests__/php.test.ts b/packages/client-generator/src/generators/__tests__/php.test.ts index 4dbd3f4506..c654c15f4e 100644 --- a/packages/client-generator/src/generators/__tests__/php.test.ts +++ b/packages/client-generator/src/generators/__tests__/php.test.ts @@ -244,6 +244,14 @@ const CAFE: ApiModel = { nextCursor: '/next', items: '/items', }, + successResponseHeaders: [ + { + name: 'pagination-total', + schema: { kind: 'scalar', scalar: 'integer' }, + required: true, + }, + { name: 'link', schema: { kind: 'scalar', scalar: 'string' } }, + ], successResponses: [ { status: '200', @@ -434,6 +442,18 @@ describe('phpGenerator (full client assembly)', () => { expect(out).toContain("return $response['body'];"); }); + it('emits a WithHeaders envelope variant only for ops with declared response headers', () => { + const out = generatePhp(); + expect(out).toContain('public function listOrdersWithHeaders('); + expect(out).toContain( + "readEnvelopeHeaders($response, [['pagination-total', 'paginationTotal', 'integer'], ['link', 'link', 'string']])" + ); + expect(out).toContain("status: $response['status']"); + // No declared headers, no variant. + expect(out).not.toContain('getOrderWithHeaders'); + expectPhpRuns(out); + }); + it('emits a Servers class with named variable arguments defaulting to the spec defaults', () => { const out = generatePhp(); expect(out).toContain('final class Servers'); diff --git a/packages/client-generator/src/generators/__tests__/python.test.ts b/packages/client-generator/src/generators/__tests__/python.test.ts index 575ba09fca..89f1301538 100644 --- a/packages/client-generator/src/generators/__tests__/python.test.ts +++ b/packages/client-generator/src/generators/__tests__/python.test.ts @@ -210,6 +210,14 @@ const CAFE: ApiModel = { nextCursor: '/next', items: '/items', }, + successResponseHeaders: [ + { + name: 'pagination-total', + schema: { kind: 'scalar', scalar: 'integer' }, + required: true, + }, + { name: 'link', schema: { kind: 'scalar', scalar: 'string' } }, + ], successResponses: [ { status: '200', @@ -401,6 +409,19 @@ describe('pythonGenerator parity features', () => { expectCompiles(out); }); + it('emits a _with_headers envelope variant only for ops with declared response headers', () => { + const out = generate(); + expect(out).toContain('def list_orders_with_headers('); + expect(out).toContain('async def list_orders_with_headers('); + expect(out).toContain(') -> Envelope[OrderPage]:'); + expect(out).toContain( + 'read_envelope_headers(response, [("pagination-total", "pagination_total", "integer"), ("link", "link", "string")])' + ); + // No declared headers, no variant. + expect(out).not.toContain('get_order_with_headers'); + expectCompiles(out); + }); + it('emits a Servers class with keyword arguments defaulting to the spec defaults', () => { const out = generate(); expect(out).toContain('class Servers:'); diff --git a/packages/client-generator/src/generators/go/AGENTS.md b/packages/client-generator/src/generators/go/AGENTS.md index bfab85d6a7..50562c4e87 100644 --- a/packages/client-generator/src/generators/go/AGENTS.md +++ b/packages/client-generator/src/generators/go/AGENTS.md @@ -23,6 +23,11 @@ runtime. Go ≥ 1.21, standard library only — zero dependencies. dispatcher; **allOf** is flattened. - **Errors:** `(T, error)` returns ARE the error mode — `errorMode` does not change the output. Non-2xx → `*APIError`; timeouts → `*TimeoutError`. +- **Response headers:** an operation that DECLARES success-response headers gains a + `WithHeaders(ctx, …) (T, Headers, error)` variant; `Headers` is a + generated struct with pointer fields (nil when absent or unparsable), coerced to + int64/bool/string. Operations without declared headers get no variant, and the + base method stays `(T, error)`. - **Servers:** when the description declares servers, one `URL(...)` function per server is emitted (named from the server description); server VARIABLES become string parameters (Go has no defaults — the doc comment states the spec default), so templated diff --git a/packages/client-generator/src/generators/go/index.ts b/packages/client-generator/src/generators/go/index.ts index 21d94a9695..b2f7392901 100644 --- a/packages/client-generator/src/generators/go/index.ts +++ b/packages/client-generator/src/generators/go/index.ts @@ -11,6 +11,7 @@ import { docText, enumValues, flattenAllOf, + headerCoerceType, identifierFor, isNullable, paginationRuleFor, @@ -326,7 +327,36 @@ function goPaginationLiteral(rule: NeutralPaginationRule): string { return `&PaginationSpec{${fields.join(', ')}}`; } -function writeGoMethod(printer: Printer, op: OperationModel, ident: string): void { +/** Declared response headers planned for the `Headers` struct: field, wire name, coerce helper. */ +function envelopeHeaderPlan( + op: OperationModel, + model: ApiModel +): Array<{ field: string; name: string; goType: string; helper: string }> { + const used = new Set(); + return (op.successResponseHeaders ?? []).map((header) => { + const base = exported(header.name); + let field = base; + let suffix = 2; + while (used.has(field)) field = `${base}${suffix++}`; + used.add(field); + const coerce = headerCoerceType(header.schema, model); + const mapping = { + integer: { goType: '*int64', helper: 'headerInt64' }, + number: { goType: '*float64', helper: 'headerFloat64' }, + boolean: { goType: '*bool', helper: 'headerBool' }, + string: { goType: '*string', helper: 'headerString' }, + }[coerce]; + return { field, name: header.name, ...mapping }; + }); +} + +function writeGoMethod( + printer: Printer, + op: OperationModel, + ident: string, + model?: ApiModel, + envelope = false +): void { const pathArgs = op.pathParams.map((param) => ({ param, go: identifierFor(param.name, { style: 'camel', reserved: GO }), @@ -335,6 +365,20 @@ function writeGoMethod(printer: Printer, op: OperationModel, ident: string): voi const hasParams = op.queryParams.length > 0; const success = successSchema(op); const returnType = success === undefined ? undefined : goType(success); + 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}`), @@ -342,19 +386,34 @@ function writeGoMethod(printer: Printer, op: OperationModel, ident: string): voi ...(hasParams ? [`params *${ident}Params`] : []), ]; const sse = sseResponse(op); - const returns = - sse !== undefined + 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) => - returnType === undefined ? `return ${errExpr}` : `return out, ${errExpr}`; - writeDocComment(printer, ident, op.summary); + envelope + ? returnType === undefined + ? `return headers, ${errExpr}` + : `return out, headers, ${errExpr}` + : returnType === undefined + ? `return ${errExpr}` + : `return out, ${errExpr}`; + const funcName = envelope ? `${ident}WithHeaders` : ident; + writeDocComment( + printer, + funcName, + envelope ? `Like ${ident}, also returning the declared response headers.` : op.summary + ); printer.block( - `func (c *Client) ${ident}(${args.join(', ')}) ${returns} {`, + `func (c *Client) ${funcName}(${args.join(', ')}) ${returns} {`, () => { if (sse === undefined && returnType !== undefined) printer.line(`var out ${returnType}`); + if (envelope) printer.line(`var headers ${ident}Headers`); printer.line(`op := operations[${JSON.stringify(op.specName ?? op.name)}]`); printer.line('authHeaders, query := resolveAuth(op.Security, c.config.Auth)'); if (hasParams) { @@ -458,7 +517,21 @@ function writeGoMethod(printer: Printer, op: OperationModel, ident: string): voi }, '}' ); - if (returnType === undefined) { + if (envelope) { + printer.block( + `if err := decodeJSON(resp, ${returnType === undefined ? 'nil' : '&out'}); err != nil {`, + () => { + printer.line(fail('err')); + }, + '}' + ); + for (const planned of headerPlan) { + printer.line( + `headers.${planned.field} = ${planned.helper}(resp.Header, ${JSON.stringify(planned.name)})` + ); + } + printer.line(returnType === undefined ? 'return headers, nil' : 'return out, headers, nil'); + } else if (returnType === undefined) { printer.line('return decodeJSON(resp, nil)'); } else { printer.block( @@ -845,6 +918,9 @@ export const goGenerator: Generator = ({ model, outputPath, emit }) => { for (const { op, ident } of goOperationIdents(model)) { writeGoMethod(printer, op, ident); + if (sseResponse(op) === undefined && (op.successResponseHeaders?.length ?? 0) > 0) { + writeGoMethod(printer, op, ident, model, true); + } const rule = paginationRules.get(ident); if (rule === undefined) continue; const success = successSchema(op); diff --git a/packages/client-generator/src/generators/php/AGENTS.md b/packages/client-generator/src/generators/php/AGENTS.md index 1505a4bf25..15e1170923 100644 --- a/packages/client-generator/src/generators/php/AGENTS.md +++ b/packages/client-generator/src/generators/php/AGENTS.md @@ -36,6 +36,11 @@ extension — zero Composer dependencies. The namespace derives from the API tit $idempotencyKey` on mutating methods. - **Non-JSON success bodies** (PDFs, images, octet streams) return the raw body as `string` — a binary download must never degrade to `void`. +- **Response headers:** an operation that DECLARES success-response headers gains a + `WithHeaders()` variant returning an `Envelope` (`data`, `headers` — coerced to + int/bool/string with camelCase keys, absent/unparsable values omitted — and `status`). + Operations without declared headers get no variant, and the base method stays + body-only (PHP cannot vary a return type on a flag). - **Servers:** when the description declares servers, a `Servers` class is emitted with one static method per server; server VARIABLES become named string arguments defaulting to the spec's defaults (`Servers::production(organizationId: 'org_x')`), so templated @@ -54,8 +59,9 @@ $idempotencyKey` on mutating methods. after operationIds (`$client->getCustomer($id)`); optional query params keep their named-argument style (`filter:`, `sort:`, `limit:`). - Collection wrappers exposing pagination RESPONSE HEADERS (`getTotalItems()`, - `getLimit()`) have no equivalent — migrate to `Items()` / `Pages()` - generators, or capture headers with a middleware callable. + `getLimit()`) map to the `WithHeaders()` envelope + (`->headers['paginationTotal']`); plain iteration maps to `Items()` / + `Pages()` generators. - Dedicated validation-exception classes exposing field errors map to `catch (ApiError $e)` + `$e->status === 422` + the decoded `$e->body`. - Session/bearer token flows map to `auth: ['bearer' => $tokenProvider]` with a diff --git a/packages/client-generator/src/generators/php/index.ts b/packages/client-generator/src/generators/php/index.ts index c31ea23f75..a872fdca1a 100644 --- a/packages/client-generator/src/generators/php/index.ts +++ b/packages/client-generator/src/generators/php/index.ts @@ -11,6 +11,7 @@ import { discriminatorCases, enumValues, flattenAllOf, + headerCoerceType, identifierFor, isNullable, paginationRuleFor, @@ -464,7 +465,27 @@ function writeRequestSetup(printer: Printer, op: OperationModel, args: MethodArg ); } -function writePhpMethod(printer: Printer, op: OperationModel, model: ApiModel): void { +/** Declared response headers as runtime coerce specs: `[wire name, camelCase key, type]`. */ +function envelopeHeaderSpecs(op: OperationModel, model: ApiModel): string { + const used = new Set(); + const specs = (op.successResponseHeaders ?? []).map((header) => { + let key = identifierFor(header.name, { style: 'camel', reserved: PHP }); + let suffix = 2; + while (used.has(key)) + key = `${identifierFor(header.name, { style: 'camel', reserved: PHP })}_${suffix++}`; + used.add(key); + const type = headerCoerceType(header.schema, model); + return `[${phpString(header.name)}, ${phpString(key)}, ${phpString(type)}]`; + }); + return `[${specs.join(', ')}]`; +} + +function writePhpMethod( + printer: Printer, + op: OperationModel, + model: ApiModel, + envelope = false +): void { const args = methodArgs(op, model, true); const sse = sseResponse(op); const success = successSchema(op); @@ -473,17 +494,25 @@ function writePhpMethod(printer: Printer, op: OperationModel, model: ApiModel): sse === undefined && success === undefined && op.successResponses.some((response) => response.contentType !== ''); - const returnType = - sse !== undefined + const returnType = envelope + ? 'Envelope' + : sse !== undefined ? '\\Generator' : success !== undefined ? phpType(success, model) : rawBody ? 'string' : 'void'; - writeDocComment(printer, methodName(op), op.summary ?? `${op.method.toUpperCase()} ${op.path}`); + const name = envelope ? `${methodName(op)}WithHeaders` : methodName(op); + writeDocComment( + printer, + name, + envelope + ? `Like ${methodName(op)}(), returning an Envelope with the declared response headers.` + : (op.summary ?? `${op.method.toUpperCase()} ${op.path}`) + ); printer.block( - `public function ${methodName(op)}(${args.signature.join(', ')}): ${returnType}`, + `public function ${name}(${args.signature.join(', ')}): ${returnType}`, () => {}, '' ); @@ -543,6 +572,18 @@ function writePhpMethod(printer: Printer, op: OperationModel, model: ApiModel): }, '}' ); + const decoded = rawBody + ? "$response['body']" + : ((success === undefined + ? undefined + : hydration(success, 'decodeJson($response)', model)) ?? 'decodeJson($response)'); + if (envelope) { + printer.line(`$data = ${decoded};`); + printer.line( + `return new Envelope(data: $data, headers: readEnvelopeHeaders($response, ${envelopeHeaderSpecs(op, model)}), status: $response['status']);` + ); + return; + } if (rawBody) { printer.line("return $response['body'];"); return; @@ -551,9 +592,7 @@ function writePhpMethod(printer: Printer, op: OperationModel, model: ApiModel): printer.line('decodeJson($response);'); return; } - const typed = - success === undefined ? undefined : hydration(success, 'decodeJson($response)', model); - printer.line(`return ${typed ?? 'decodeJson($response)'};`); + printer.line(`return ${decoded};`); }, '}' ); @@ -822,6 +861,9 @@ export const phpGenerator: Generator = ({ model, outputPath, emit }) => { for (const op of operations) { writePhpMethod(printer, op, model); + if (sseResponse(op) === undefined && (op.successResponseHeaders?.length ?? 0) > 0) { + writePhpMethod(printer, op, model, true); + } const rule = paginationRules.get(op.name); if (rule === undefined) continue; const success = successSchema(op); diff --git a/packages/client-generator/src/generators/python/AGENTS.md b/packages/client-generator/src/generators/python/AGENTS.md index 936bb13376..ce75d4d02f 100644 --- a/packages/client-generator/src/generators/python/AGENTS.md +++ b/packages/client-generator/src/generators/python/AGENTS.md @@ -26,6 +26,11 @@ One self-contained `.py`: typed dataclass models, a sync `Client` and an a hydrates wins — see `_decode.py`). **allOf** is flattened via `flattenAllOf`. - **Errors:** `errorMode` maps to raising `ApiError` (default) or returning a `Result` dataclass — the only generator with both modes outside TypeScript. +- **Response headers:** an operation that DECLARES success-response headers gains a + `_with_headers()` variant (sync and async) returning `Envelope[T]` — `data`, + `headers` (coerced to int/bool/str with snake_case keys; absent/unparsable values + omitted), and the raw `response`. Operations without declared headers get no + variant, and the base method stays body-only. - **Servers:** when the description declares servers, a `Servers` class is emitted with one static method per server; server VARIABLES become keyword arguments defaulting to the spec's defaults (`Servers.production(organization_id="org_x")`), so templated base diff --git a/packages/client-generator/src/generators/python/index.ts b/packages/client-generator/src/generators/python/index.ts index f9b78e2654..151eac7341 100644 --- a/packages/client-generator/src/generators/python/index.ts +++ b/packages/client-generator/src/generators/python/index.ts @@ -11,6 +11,7 @@ import { docText, enumValues, flattenAllOf, + headerCoerceType, identifierFor, isNullable, RESERVED_WORDS, @@ -332,12 +333,29 @@ function paginationSpec( }; } +/** Declared response headers as runtime coerce specs: `("wire-name", "snake_key", "type")`. */ +function envelopeHeaderSpecs(op: OperationModel, model: ApiModel): string { + const used = new Set(); + const specs = (op.successResponseHeaders ?? []).map((header) => { + const base = identifierFor(header.name, { style: 'snake', reserved: PY }); + let key = base; + let suffix = 2; + while (used.has(key)) key = `${base}_${suffix++}`; + used.add(key); + const type = headerCoerceType(header.schema, model); + return `(${JSON.stringify(header.name)}, ${JSON.stringify(key)}, ${JSON.stringify(type)})`; + }); + return `[${specs.join(', ')}]`; +} + function writeMethod( printer: Printer, op: OperationModel, ident: string, errorMode: 'throw' | 'result', - isAsync: boolean + isAsync: boolean, + model?: ApiModel, + envelope = false ): void { const pathArgs = op.pathParams.map((param) => ({ param, @@ -362,8 +380,9 @@ function writeMethod( ]; const success = successSchema(op); const sse = sseResponse(op); - const returns = - sse !== undefined + const returns = envelope + ? `Envelope[${success === undefined ? 'None' : pythonType(success)}]` + : sse !== undefined ? `${isAsync ? 'AsyncIterator' : 'Iterator'}[ServerSentEvent]` : errorMode === 'result' ? 'Result' @@ -376,8 +395,14 @@ function writeMethod( const awaitKw = isAsync ? 'await ' : ''; const sendFn = isAsync ? 'send_async' : 'send'; const signature = ['self', ...positional, ...bodyArg, '*', ...kwargs].join(', '); - printer.block(`${prefix} ${ident}(${signature}) -> ${returns}:`, () => { - writeDocstring(printer, op.summary); + const defName = envelope ? `${ident}_with_headers` : ident; + printer.block(`${prefix} ${defName}(${signature}) -> ${returns}:`, () => { + writeDocstring( + printer, + envelope + ? `Like ${ident}(), returning an Envelope with the declared response headers.` + : op.summary + ); printer.line(`op = _OPERATIONS["${ident}"]`); printer.line('auth_headers, auth_query = resolve_auth(op.get("security") or [], self._auth)'); printer.line('params: Dict[str, Any] = dict(auth_query)'); @@ -414,7 +439,16 @@ function writeMethod( ); const decoded = success === undefined ? 'None' : `decode(${pythonType(success)}, _safe_json(response))`; - if (errorMode === 'result') { + 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)'); }); @@ -565,6 +599,9 @@ function writeClientClass( printer.blank(); for (const { op, ident } of operationIdents(model)) { writeMethod(printer, op, ident, errorMode, isAsync); + if (sseResponse(op) === undefined && (op.successResponseHeaders?.length ?? 0) > 0) { + writeMethod(printer, op, ident, errorMode, isAsync, model, true); + } const spec = paginationSpecs.get(ident); if (spec !== undefined) { const success = successSchema(op); diff --git a/tests/e2e/generate-client/examples/ejected-generator/generators/AGENTS.md b/tests/e2e/generate-client/examples/ejected-generator/generators/AGENTS.md index a498db410b..822fa0144d 100644 --- a/tests/e2e/generate-client/examples/ejected-generator/generators/AGENTS.md +++ b/tests/e2e/generate-client/examples/ejected-generator/generators/AGENTS.md @@ -55,6 +55,7 @@ discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, | `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). | | `Printer` | Indentation-aware text builder — no manual whitespace bookkeeping. | | `docText(description)` | Description as trimmed lines for any comment syntax. | diff --git a/tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md b/tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md index 47ef5325c8..129c26d4e5 100644 --- a/tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md +++ b/tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md @@ -35,6 +35,11 @@ extension — zero Composer dependencies. The namespace derives from the API tit $idempotencyKey` on mutating methods. - **Non-JSON success bodies** (PDFs, images, octet streams) return the raw body as `string` — a binary download must never degrade to `void`. +- **Response headers:** an operation that DECLARES success-response headers gains a + `WithHeaders()` variant returning an `Envelope` (`data`, `headers` — coerced to + int/bool/string with camelCase keys, absent/unparsable values omitted — and `status`). + Operations without declared headers get no variant, and the base method stays + body-only (PHP cannot vary a return type on a flag). - **Servers:** when the description declares servers, a `Servers` class is emitted with one static method per server; server VARIABLES become named string arguments defaulting to the spec's defaults (`Servers::production(organizationId: 'org_x')`), so templated @@ -53,8 +58,9 @@ $idempotencyKey` on mutating methods. after operationIds (`$client->getCustomer($id)`); optional query params keep their named-argument style (`filter:`, `sort:`, `limit:`). - Collection wrappers exposing pagination RESPONSE HEADERS (`getTotalItems()`, - `getLimit()`) have no equivalent — migrate to `Items()` / `Pages()` - generators, or capture headers with a middleware callable. + `getLimit()`) map to the `WithHeaders()` envelope + (`->headers['paginationTotal']`); plain iteration maps to `Items()` / + `Pages()` generators. - Dedicated validation-exception classes exposing field errors map to `catch (ApiError $e)` + `$e->status === 422` + the decoded `$e->body`. - Session/bearer token flows map to `auth: ['bearer' => $tokenProvider]` with a From 4ca28a5203f023b146c25bed6d4a759033b3cfd5 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 4 Aug 2026 18:02:49 +0300 Subject: [PATCH 076/211] =?UTF-8?q?ci:=20fix=20PR=20checks=20=E2=80=94=20g?= =?UTF-8?q?o=20compile-bar=20timeouts=20on=20cold=20caches,=20hard=20tabs?= =?UTF-8?q?=20in=20the=20go=20docs=20snippet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/@v2/guides/use-generated-client.md | 8 ++++---- .../__tests__/go-runtime-embed.test.ts | 3 +++ .../src/generators/__tests__/go.test.ts | 4 ++++ tests/e2e/generate-client/go.test.ts | 20 ++++++++++++------- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index 1b908d672e..dc29f787ae 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -101,10 +101,10 @@ api := client.New(client.Config{Auth: client.Auth{Bearer: func() string { return order, err := api.GetOrder(ctx, "ord_123") for order, err := range api.ListOrdersItems(ctx, nil) { - if err != nil { - break - } - fmt.Println(order.Id) + if err != nil { + break + } + fmt.Println(order.Id) } ``` 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 4119247bfd..29a3dfa710 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 @@ -7,6 +7,9 @@ import { GO_RUNTIME_SOURCE } from '../../emitters/go-runtime-sources.js'; const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); const hasGo = spawnSync('go', ['version']).status === 0; +// `go build`/`go vet` on a cold CI cache compile the stdlib — well over the 5s default. +vi.setConfig({ testTimeout: 180_000 }); + describe('GO_RUNTIME_SOURCE (the embedded Go runtime)', () => { it('embeds the load-bearing declarations', () => { for (const declaration of [ diff --git a/packages/client-generator/src/generators/__tests__/go.test.ts b/packages/client-generator/src/generators/__tests__/go.test.ts index c903148580..0c467262f7 100644 --- a/packages/client-generator/src/generators/__tests__/go.test.ts +++ b/packages/client-generator/src/generators/__tests__/go.test.ts @@ -8,6 +8,10 @@ import { goGenerator, renderGoModels } from '../go/index.js'; const hasGo = spawnSync('go', ['version']).status === 0; +// Every `expectGoCompiles` bar shells out to `go build`; the first build on a cold +// CI cache compiles the stdlib and takes well over the 5s default. +vi.setConfig({ testTimeout: 180_000 }); + /** Assert the rendered source is compilable Go (skipped without the toolchain). */ function expectGoCompiles(source: string): void { if (!hasGo) return; diff --git a/tests/e2e/generate-client/go.test.ts b/tests/e2e/generate-client/go.test.ts index 6739be3d91..96cd0600ff 100644 --- a/tests/e2e/generate-client/go.test.ts +++ b/tests/e2e/generate-client/go.test.ts @@ -26,13 +26,19 @@ describe('generate-client go generator (end-to-end)', () => { expect(existsSync(generatedFile)).toBe(true); }); - it.skipIf(!hasGo)('the generated client compiles (go build)', () => { - const result = spawnSync('go', ['build', '-o', 'smoke', '.'], { - cwd: consumerDir, - encoding: 'utf-8', - }); - expect(result.status, result.stderr).toBe(0); - }); + it.skipIf(!hasGo)( + 'the generated client compiles (go build)', + () => { + const result = spawnSync('go', ['build', '-o', 'smoke', '.'], { + cwd: consumerDir, + encoding: 'utf-8', + }); + expect(result.status, result.stderr).toBe(0); + }, + // The first build on a cold CI cache compiles the stdlib and takes well over + // the 5s default. + 180_000 + ); it.skipIf(!hasGo)( 'the compiled smoke runs real HTTP: hydration, bodies, APIError', From ba4fa895564e52bf6267d5a8d4eeaf88682dcc94 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 4 Aug 2026 18:12:18 +0300 Subject: [PATCH 077/211] fix: gate mock-emitter code interpolations behind an identifier check (CodeQL js/bad-code-sanitization) --- .../client-generator/src/emitters/mock.ts | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/client-generator/src/emitters/mock.ts b/packages/client-generator/src/emitters/mock.ts index 8370338284..cb9f6c8770 100644 --- a/packages/client-generator/src/emitters/mock.ts +++ b/packages/client-generator/src/emitters/mock.ts @@ -16,6 +16,7 @@ import { type SchemaModel, } from '../intermediate-representation/model.js'; import { fakerExpression } from './faker.js'; +import { isIdentifier } from './identifier.js'; import { expr, isObjectValue, @@ -126,12 +127,25 @@ function factoryFor(named: NamedSchemaModel, model: ApiModel, opts: MockOptions) ].join('\n'); } +/** + * The interpolation gate for values that land in emitted CODE positions (binding + * names, `http.` member access). The pipeline sanitizes operation names + * before any emitter runs; this re-checks at the construction site so a hostile + * name can never become code even if that invariant regresses. + */ +function codeIdent(value: string): string { + if (!isIdentifier(value)) { + throw new Error(`Unsafe identifier in mock emission: ${JSON.stringify(value)}`); + } + return value; +} + /** `export const Handler = (override?: ) => http.('', () => );`. */ function handlerFor(op: OperationModel, model: ApiModel, opts: MockOptions): string { const override = overrideParam(op, model, opts); const params = override ?? ''; - const call = `http.${op.method}(${JSON.stringify(mswPath(op.path))}, () => ${responseExpression(op, model, opts)})`; - return `export const ${op.name}Handler = (${params}) => ${call};`; + const call = `http.${codeIdent(op.method)}(${JSON.stringify(mswPath(op.path))}, () => ${responseExpression(op, model, opts)})`; + return `export const ${codeIdent(op.name)}Handler = (${params}) => ${call};`; } /** @@ -147,8 +161,8 @@ function errorHandlerFor(op: OperationModel, model: ApiModel, opts: MockOptions) const first = op.errorResponses[0]; const sampled = renderMockValue(bodyValue(first.schema, model, opts), ''); const resolver = `() => HttpResponse.json(body ?? ${sampled}, { status })`; - const call = `http.${op.method}(${JSON.stringify(mswPath(op.path))}, ${resolver})`; - return `export const ${op.name}ErrorHandler = (status: ${errorStatusType(op)}, body?: ${errorBodyType(op)}) => ${call};`; + const call = `http.${codeIdent(op.method)}(${JSON.stringify(mswPath(op.path))}, ${resolver})`; + return `export const ${codeIdent(op.name)}ErrorHandler = (status: ${errorStatusType(op)}, body?: ${errorBodyType(op)}) => ${call};`; } /** @@ -239,7 +253,7 @@ function statusCode(status: ResponseBodyModel['status'] | undefined): number { /** `export const handlers = [Handler(), …];`. */ function handlersArray(operations: OperationModel[]): string { - const elements = operations.map((op) => `${op.name}Handler()`).join(', '); + const elements = operations.map((op) => `${codeIdent(op.name)}Handler()`).join(', '); return `export const handlers = [${elements}];`; } From f5c45b789823cc18f4586e5bc87e12f48d406fcc Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 4 Aug 2026 18:15:09 +0300 Subject: [PATCH 078/211] docs: document both ejected AGENTS.md drops and the language SDKs' envelope/servers features --- docs/@v2/commands/eject-generator.md | 6 ++++-- docs/@v2/guides/customize-client-generation.md | 2 +- docs/@v2/guides/use-generated-client.md | 7 ++++--- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/@v2/commands/eject-generator.md b/docs/@v2/commands/eject-generator.md index e1b9c97454..764651b468 100644 --- a/docs/@v2/commands/eject-generator.md +++ b/docs/@v2/commands/eject-generator.md @@ -28,11 +28,13 @@ redocly eject-generator php --force ## How it works -Ejecting writes three things: +Ejecting writes four things: - `/.mjs` — the generator, the exact code the built-in runs, readable plain ESM. - `/.pristine/.mjs` — a pristine snapshot (commit it); `--update` uses it as the merge base. -- `/AGENTS.md` — the generator-authoring guide for your coding agent, marker-delimited so your own additions survive refreshes. +- `/AGENTS.md` — the generator-authoring guide for your coding agent (the contract, the model shape, the helper library), shared by every ejected generator and marker-delimited so your own additions survive refreshes. +- `/.AGENTS.md` — this generator's own design doc: the decisions its code implements and the modify loop (edit the design first, then make the code match). + It's dropped once and then it's yours — evolve it with your customizations. The ejected file imports the authoring toolkit, so install it once: diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index 63c7cb6f0b..cbcd6b187a 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -67,7 +67,7 @@ The fastest path to a customized generator is [`redocly eject-generator `](../commands/eject-generator.md): it vendors a built-in language generator (`python`, `go`, `php`) into `./generators/` as an editable file, with a pristine snapshot for [three-way updates](../commands/eject-generator.md#how-it-works) and the `AGENTS.md` authoring guide for your coding agent. An ejected-unmodified generator produces byte-identical output, and the path entry takes over the built-in name — regeneration survives every customization. -Eject drops `AGENTS.md` next to the generator: your agent reads it to learn the model shape, the helper library, and the verify loop (edit the generator → `redocly generate-client` → review the client diff — generated files are never hand-edited). +Eject drops two guides next to the generator: the shared `AGENTS.md` authoring guide (the model shape, the helper library, and the verify loop — edit the generator → `redocly generate-client` → review the client diff; generated files are never hand-edited) and the generator's own `.AGENTS.md` design doc, which your agent treats as the source of truth: state the change there first, then make the code match. ## Custom generators diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index dc29f787ae..81aa84ff04 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -59,7 +59,7 @@ To ship it as a real bin, compile with `tsc` and point `package.json`'s `bin` at ### Python SDK The `python` generator emits a self-contained `.py` next to the configured output — a full Python SDK over [httpx](https://www.python-httpx.org/) (`pip install httpx`, Python ≥ 3.9): -typed dataclass models (allOf flattened, enums, discriminated unions), a `Client` and an `AsyncClient` with one method per operation, auth, retries with `Retry-After` and jittered backoff, timeouts, idempotency keys, middleware hooks, pagination iterators (`_pages()` / `_items()`, `async for` variants), SSE streaming, and multipart bodies. +typed dataclass models (allOf flattened, enums, discriminated unions decoded by their discriminator), a `Client` and an `AsyncClient` with one method per operation, auth, retries with `Retry-After` and jittered backoff, timeouts, idempotency keys, middleware hooks, pagination iterators (`_pages()` / `_items()`, `async for` variants), SSE streaming, multipart bodies, `_with_headers()` envelope variants for operations that declare response headers, and a `Servers` class for templated server URLs. `errorMode` maps to raising `ApiError` (default) or returning a `Result` dataclass. No TypeScript is involved: generating with only `python` selected does not require the `typescript` package. @@ -74,7 +74,7 @@ for order in client.list_orders_items(limit=50): ### PHP SDK The `php` generator emits a self-contained `.php` — a full PHP SDK over the curl extension (zero Composer dependencies, PHP ≥ 8.1): -promoted-constructor classes with `fromArray`/`toArray` hydration (allOf flattened, native backed enums, `match`-based discriminated-union dispatchers), a `Client` with one typed method per operation (optional query params as nullable named arguments), auth, retries with `Retry-After` and jittered backoff, timeouts, idempotency keys, middleware callables, pagination generators (`Pages()` / `Items()`), SSE streaming, and multipart bodies. +promoted-constructor classes with `fromArray`/`toArray` hydration (allOf flattened, native backed enums, `match`-based discriminated-union dispatchers), a `Client` with one typed method per operation (optional query params as nullable named arguments), auth, retries with `Retry-After` and jittered backoff, timeouts, idempotency keys, middleware callables, pagination generators (`Pages()` / `Items()`), SSE streaming, multipart bodies, binary downloads (non-JSON success bodies return the raw `string`), `WithHeaders()` envelope variants for operations that declare response headers, and a `Servers` class for templated server URLs. Exceptions are the error mode (`ApiError` / `TimeoutError`); `errorMode` does not change the output. The namespace derives from the API title (for example `CafeOrdersApi`). @@ -92,7 +92,7 @@ foreach ($client->listOrdersItems(limit: 50) as $order) { ### Go SDK The `go` generator emits a self-contained `.go` — a full Go SDK over the standard library (zero dependencies, Go ≥ 1.21): -structs with `json` tags (allOf flattened, typed-const enums, discriminated-union unmarshal dispatchers), a `Client` with one `(T, error)` method per operation taking a `context.Context`, auth, retries with `Retry-After` and jittered backoff, per-attempt timeouts, idempotency keys, middleware hooks, pagination iterators (`Pages` / `Items`, `range`-over-func style), SSE streaming, and multipart bodies. +structs with `json` tags (allOf flattened, typed-const enums, discriminated-union unmarshal dispatchers), a `Client` with one `(T, error)` method per operation taking a `context.Context`, auth, retries with `Retry-After` and jittered backoff, per-attempt timeouts, idempotency keys, middleware hooks, pagination iterators (`Pages` / `Items`, `range`-over-func style), SSE streaming, multipart bodies, `WithHeaders` envelope variants (a typed headers struct) for operations that declare response headers, and `URL` helpers for templated server URLs. Go's `(T, error)` returns are the error mode; `errorMode` does not change the output. The iterators are `func(yield func(T, error) bool)` values: `for … range` over them needs Go ≥ 1.23; on 1.21–1.22 call them with a callback instead. @@ -395,6 +395,7 @@ const envelope = await client.listCustomers({ params: { limit: 1 } }, { envelope - The TanStack Query and SWR wrappers don't accept `envelope`. It's excluded from their options and stripped from the forwarded call, so cached data is always the plain body. Call the sdk function directly when you need headers. +- The Python, PHP, and Go SDKs expose the same information as separate variants — `_with_headers()`, `WithHeaders()`, and `WithHeaders` — emitted only for operations that declare success-response headers (those languages cannot vary a return type on a flag). ## Runtime validation From 3167889644b5c17c986e3b9f238403697a3cc2e5 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 5 Aug 2026 12:22:28 +0300 Subject: [PATCH 079/211] fix: drop the client-generator prepublishOnly rebuild that broke package publishing --- packages/client-generator/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/client-generator/package.json b/packages/client-generator/package.json index 9b189ca487..0cd64f3406 100644 --- a/packages/client-generator/package.json +++ b/packages/client-generator/package.json @@ -34,8 +34,7 @@ "scripts": { "examples:regen": "node scripts/regenerate-examples.mjs", "prepare": "node scripts/generate-runtime-sources.mjs && node scripts/generate-eject-assets.mjs", - "typecheck:examples": "node scripts/typecheck-examples.mjs", - "prepublishOnly": "rm -rf lib *.tsbuildinfo && tsc -b tsconfig.build.json" + "typecheck:examples": "node scripts/typecheck-examples.mjs" }, "license": "MIT", "repository": { From 33dd6ce4f19461d1535728ac5d74316e73bf4fc8 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 5 Aug 2026 13:06:48 +0300 Subject: [PATCH 080/211] fix: honor the serverUrl option in the python, go, and php generators --- .../src/generators/__tests__/go.test.ts | 10 ++++++++++ .../src/generators/__tests__/php.test.ts | 10 ++++++++++ .../src/generators/__tests__/python.test.ts | 10 ++++++++++ packages/client-generator/src/generators/go/index.ts | 4 +++- packages/client-generator/src/generators/php/index.ts | 4 +++- .../client-generator/src/generators/python/index.ts | 11 +++++++---- 6 files changed, 43 insertions(+), 6 deletions(-) diff --git a/packages/client-generator/src/generators/__tests__/go.test.ts b/packages/client-generator/src/generators/__tests__/go.test.ts index 0c467262f7..44bdf0de56 100644 --- a/packages/client-generator/src/generators/__tests__/go.test.ts +++ b/packages/client-generator/src/generators/__tests__/go.test.ts @@ -410,6 +410,16 @@ describe('goGenerator parity features', () => { expectGoCompiles(out); }); + it('bakes the serverUrl option, not just the description server', () => { + const files = goGenerator({ + model: CAFE, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: { serverUrl: 'https://override.example' }, + }); + expect(files[0].content).toContain('config.ServerURL = "https://override.example"'); + }); + it('emits one URL function per declared server with variables as parameters', () => { const out = generateGo(); expect(out).toContain('func LiveServerURL(organizationId string) string {'); diff --git a/packages/client-generator/src/generators/__tests__/php.test.ts b/packages/client-generator/src/generators/__tests__/php.test.ts index c654c15f4e..e0bbd901aa 100644 --- a/packages/client-generator/src/generators/__tests__/php.test.ts +++ b/packages/client-generator/src/generators/__tests__/php.test.ts @@ -454,6 +454,16 @@ describe('phpGenerator (full client assembly)', () => { expectPhpRuns(out); }); + it('bakes the serverUrl option, not just the description server', () => { + const files = phpGenerator({ + model: CAFE, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: { serverUrl: 'https://override.example' }, + }); + expect(files[0].content).toContain("$this->config->serverUrl = 'https://override.example';"); + }); + it('emits a Servers class with named variable arguments defaulting to the spec defaults', () => { const out = generatePhp(); expect(out).toContain('final class Servers'); diff --git a/packages/client-generator/src/generators/__tests__/python.test.ts b/packages/client-generator/src/generators/__tests__/python.test.ts index 89f1301538..206ec95eec 100644 --- a/packages/client-generator/src/generators/__tests__/python.test.ts +++ b/packages/client-generator/src/generators/__tests__/python.test.ts @@ -422,6 +422,16 @@ describe('pythonGenerator parity features', () => { expectCompiles(out); }); + it('bakes the serverUrl option, not just the description server', () => { + const files = pythonGenerator({ + model: CAFE, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: { serverUrl: 'https://override.example' }, + }); + expect(files[0].content).toContain('server_url: str = "https://override.example"'); + }); + it('emits a Servers class with keyword arguments defaulting to the spec defaults', () => { const out = generate(); expect(out).toContain('class Servers:'); diff --git a/packages/client-generator/src/generators/go/index.ts b/packages/client-generator/src/generators/go/index.ts index b2f7392901..6a30908690 100644 --- a/packages/client-generator/src/generators/go/index.ts +++ b/packages/client-generator/src/generators/go/index.ts @@ -906,7 +906,9 @@ export const goGenerator: Generator = ({ model, outputPath, emit }) => { printer.block( 'if config.ServerURL == "" {', () => { - printer.line(`config.ServerURL = ${JSON.stringify(model.serverUrl ?? '')}`); + printer.line( + `config.ServerURL = ${JSON.stringify(emit.serverUrl ?? model.serverUrl ?? '')}` + ); }, '}' ); diff --git a/packages/client-generator/src/generators/php/index.ts b/packages/client-generator/src/generators/php/index.ts index a872fdca1a..b881571fdd 100644 --- a/packages/client-generator/src/generators/php/index.ts +++ b/packages/client-generator/src/generators/php/index.ts @@ -850,7 +850,9 @@ export const phpGenerator: Generator = ({ model, outputPath, emit }) => { printer.block( "if ($this->config->serverUrl === '') {", () => { - printer.line(`$this->config->serverUrl = ${phpString(model.serverUrl ?? '')};`); + printer.line( + `$this->config->serverUrl = ${phpString(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 151eac7341..4f74b1cf28 100644 --- a/packages/client-generator/src/generators/python/index.ts +++ b/packages/client-generator/src/generators/python/index.ts @@ -566,7 +566,8 @@ function writeClientClass( model: ApiModel, errorMode: 'throw' | 'result', isAsync: boolean, - paginationSpecs: Map | undefined> + paginationSpecs: Map | undefined>, + serverUrl: string ): void { const name = isAsync ? 'AsyncClient' : 'Client'; const httpType = isAsync ? 'httpx.AsyncClient' : 'httpx.Client'; @@ -576,7 +577,7 @@ function writeClientClass( `${isAsync ? 'Async ' : ''}client for ${model.title} (${model.version}).` ); printer.block( - `def __init__(self, server_url: str = ${JSON.stringify(model.serverUrl ?? '')}, *, ` + + `def __init__(self, server_url: str = ${JSON.stringify(serverUrl)}, *, ` + 'auth: Optional[Dict[str, Any]] = None, headers: Optional[Dict[str, str]] = None, ' + 'timeout: Optional[float] = None, retry: Optional[Dict[str, Any]] = None, ' + 'middleware: Optional[List[Any]] = None, idempotency_key: Any = None, ' + @@ -699,8 +700,10 @@ export const pythonGenerator: Generator = ({ model, outputPath, emit }) => { printer.blank(); printer.blank(); - writeClientClass(printer, model, errorMode, false, paginationSpecs); - writeClientClass(printer, model, errorMode, true, paginationSpecs); + // The `serverUrl` option overrides the description's server, like the TS sdk. + const serverUrl = emit.serverUrl ?? model.serverUrl ?? ''; + writeClientClass(printer, model, errorMode, false, paginationSpecs, serverUrl); + writeClientClass(printer, model, errorMode, true, paginationSpecs, serverUrl); return [{ path: outputPath.replace(/\.[^.\\/]+$/, '.py'), content: printer.toString() }]; }; From 2adf755e3d894f70a07981f5644fece50027ef95 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 5 Aug 2026 13:45:31 +0300 Subject: [PATCH 081/211] test: give the cli e2e cases room for tsx startup --- tests/e2e/generate-client/cli.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/e2e/generate-client/cli.test.ts b/tests/e2e/generate-client/cli.test.ts index a798ba47cd..4143be76d3 100644 --- a/tests/e2e/generate-client/cli.test.ts +++ b/tests/e2e/generate-client/cli.test.ts @@ -13,6 +13,10 @@ const clientDir = join(consumerDir, 'client'); const SERVER_PORT = 3108; const SERVER_BASE = `http://127.0.0.1:${SERVER_PORT}`; +// Every case below spawns the generated CLI through `tsx` (often several times), and +// TypeScript startup alone can approach the 5s default on a loaded machine. +vi.setConfig({ testTimeout: 120_000 }); + /** Run the generated CLI with tsx; returns exit code + parsed streams. */ function runCliBin(args: string[], env: Record = {}) { const result = spawnSync(tsxBin, [join(clientDir, 'client.cli.ts'), ...args], { From 631a9e4cafdb5fbc8c84c923a1a93b4935ad2ab4 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 5 Aug 2026 13:47:21 +0300 Subject: [PATCH 082/211] feat: warn or fail instead of silently dropping options a language generator can't apply --- docs/@v2/configuration/reference/client.md | 10 ++--- packages/client-generator/src/generate.ts | 2 +- .../src/generators/__tests__/index.test.ts | 38 +++++++++++++++++++ .../client-generator/src/generators/index.ts | 7 ++-- .../client-generator/src/generators/meta.ts | 36 +++++++++++++++++- .../client-generator/src/generators/types.ts | 7 ++++ packages/client-generator/src/pipeline.ts | 5 ++- 7 files changed, 92 insertions(+), 13 deletions(-) diff --git a/docs/@v2/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md index d8b51005ae..8e2b66c334 100644 --- a/docs/@v2/configuration/reference/client.md +++ b/docs/@v2/configuration/reference/client.md @@ -20,11 +20,11 @@ For runs without a configuration file, declare pagination per operation with the | Option | Type | Description | | ---------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `generators` | [string] | Generators to run, in order. Each entry is a built-in name (`sdk`, `zod`, `tanstack-query` — or its `-vue`/`-svelte`/`-solid` variants — `swr`, `mock`, `transformers`, `cli`, `python`, `go`, `php`) or a custom generator's path or package name. | -| `outputMode` | string | File layout: `single` or `split`. | -| `runtime` | string | Runtime distribution: `inline` or `package`. | -| `importExt` | string | Extension in generated relative imports: `js` (default, for tsc and bundlers) or `ts` (for Node's built-in type stripping). | -| `argsStyle` | string | How operation inputs are passed: `flat` or `grouped`. | -| `errorMode` | string | How operations report HTTP errors: `throw` or `result`. | +| `outputMode` | string | File layout: `single` or `split`. TypeScript output only — the `python`, `go`, and `php` SDKs always emit one self-contained file. | +| `runtime` | string | Runtime distribution: `inline` or `package`. TypeScript output only — the `python`, `go`, and `php` SDKs always embed their runtime. | +| `importExt` | string | Extension in generated relative imports: `js` (default, for tsc and bundlers) or `ts` (for Node's built-in type stripping). TypeScript output only. | +| `argsStyle` | string | How operation inputs are passed: `flat` or `grouped`. 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 are throw-only (their language idiom IS the error mode) and reject `result`. | | `dateType` | string | Type of `date`/`date-time` fields: `string` or `Date`. | | `mockData` | string | Data mode for the `mock` generator: `static` or `faker`. | | `mockSeed` | number | Seed for `faker`-mode mocks. | diff --git a/packages/client-generator/src/generate.ts b/packages/client-generator/src/generate.ts index 27306cb657..30d2ce1c3e 100644 --- a/packages/client-generator/src/generate.ts +++ b/packages/client-generator/src/generate.ts @@ -45,7 +45,7 @@ export function collectGeneratedFiles( const registry = options.registry ?? builtinGenerators(); // Fail fast on an incompatible selection (missing prerequisite, unsupported // error-mode/date-type/runtime) before producing any file. - validateGenerators(options.generators, options.emit, registry); + validateGenerators(options.generators, options.emit, registry, options.outputMode); return runGenerators(model, { ...options, registry }); } diff --git a/packages/client-generator/src/generators/__tests__/index.test.ts b/packages/client-generator/src/generators/__tests__/index.test.ts index 6a16b18faf..70ec3319f2 100644 --- a/packages/client-generator/src/generators/__tests__/index.test.ts +++ b/packages/client-generator/src/generators/__tests__/index.test.ts @@ -1,3 +1,5 @@ +import { logger } from '@redocly/openapi-core'; + import { NotSupportedError } from '../../errors.js'; import { builtinGenerators, validateGenerators } from '../index.js'; import { sdkGenerator } from '../sdk.js'; @@ -55,6 +57,42 @@ describe('validateGenerators', () => { ); }); + it('rejects --error-mode result for the go and php SDKs (their idiom IS the error mode)', () => { + for (const language of ['go', 'php']) { + expect(() => validateGenerators([language], { errorMode: 'result' })).toThrow( + /does not support --error-mode "result"/ + ); + // Throw mode — what they actually emit — stays valid. + expect(() => validateGenerators([language], { errorMode: 'throw' })).not.toThrow(); + } + // python implements both modes. + expect(() => validateGenerators(['python'], { errorMode: 'result' })).not.toThrow(); + }); + + it('warns (never silently drops) when a language SDK ignores an option the user set', () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + try { + // `outputMode` travels beside `emit`, hence the trailing argument. + validateGenerators(['php'], { runtime: 'package', argsStyle: 'grouped' }, undefined, 'split'); + const messages = warn.mock.calls.map(([message]) => message).join(''); + expect(messages).toContain('the "php" generator ignores outputMode'); + expect(messages).toContain('the "php" generator ignores runtime'); + expect(messages).toContain('the "php" generator ignores argsStyle'); + + // Defaults must stay quiet: only an EXPLICIT option warns. + warn.mockClear(); + validateGenerators(['php'], {}); + expect(warn).not.toHaveBeenCalled(); + + // The TypeScript sdk applies all of them — no warning. + warn.mockClear(); + validateGenerators(['sdk'], { runtime: 'package', argsStyle: 'grouped' }, undefined, 'split'); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + it('throws NotSupportedError for an unknown generator name', () => { expect(() => validateGenerators(['nope' as never], {})).toThrow(NotSupportedError); }); diff --git a/packages/client-generator/src/generators/index.ts b/packages/client-generator/src/generators/index.ts index 5a344acbdd..7d1f23ee95 100644 --- a/packages/client-generator/src/generators/index.ts +++ b/packages/client-generator/src/generators/index.ts @@ -9,7 +9,7 @@ import { sdkGenerator, sdkSample } from './sdk.js'; import { swrGenerator } from './swr.js'; import { tanstackQueryGenerator } from './tanstack-query.js'; import { transformersGenerator } from './transformers.js'; -import type { GeneratorDescriptor, GeneratorName } from './types.js'; +import type { GeneratorDescriptor, GeneratorName, OutputMode } from './types.js'; import { zodGenerator } from './zod.js'; export type { @@ -62,7 +62,8 @@ export function builtinGenerators(): Map { export function validateGenerators( names: string[], emit: EmitOptions, - registry: Map = builtinGenerators() + registry: Map = builtinGenerators(), + outputMode?: OutputMode ): void { - validateSelection(names, emit, registry); + validateSelection(names, emit, registry, outputMode); } diff --git a/packages/client-generator/src/generators/meta.ts b/packages/client-generator/src/generators/meta.ts index 7f6df6093b..9734683588 100644 --- a/packages/client-generator/src/generators/meta.ts +++ b/packages/client-generator/src/generators/meta.ts @@ -3,9 +3,11 @@ // table and dynamic-imports only the generators actually selected; the sync // `/generate` registry in index.ts derives from it, so the metadata has one home. +import { logger } from '@redocly/openapi-core'; + import type { EmitOptions } from '../emitters/emit-options.js'; import { NotSupportedError } from '../errors.js'; -import type { GeneratorDescriptor, GeneratorName } from './types.js'; +import type { GeneratorDescriptor, GeneratorName, OutputMode } from './types.js'; export type BuiltinMeta = Omit & { load: () => Promise>; @@ -20,6 +22,18 @@ function tanstackQuery(framework: 'react' | 'vue' | 'svelte' | 'solid'): Builtin }; } +/** + * The TypeScript-only knobs a standalone language SDK cannot apply: it always emits + * one self-contained file with the runtime embedded, and each language passes inputs + * its own idiomatic way (keyword arguments, named arguments, a params struct). + */ +const LANGUAGE_SDK_NOT_APPLICABLE: BuiltinMeta['notApplicable'] = { + outputMode: 'it always emits one self-contained file', + runtime: 'the runtime is always embedded in the generated file', + argsStyle: "inputs follow the target language's own idiom", + importExt: 'the generated file has no relative imports', +}; + export const BUILTIN_META: Record = { // sdk is the base client; zod emits a standalone schema module importing nothing from it. sdk: { @@ -61,15 +75,22 @@ export const BUILTIN_META: Record = { // python emits a standalone full Python SDK (httpx) — no TypeScript involved, // so a python-only selection never loads the `typescript` package. python: { + notApplicable: LANGUAGE_SDK_NOT_APPLICABLE, load: () => import('./python/index.js').then((m) => ({ run: m.pythonGenerator, sample: m.pythonSample })), }, // go emits a standalone full Go SDK (stdlib-only) — no TypeScript involved. + // `(T, error)` returns ARE its error mode, so `result` has no Go rendering. go: { + errorModes: ['throw'], + notApplicable: LANGUAGE_SDK_NOT_APPLICABLE, load: () => import('./go/index.js').then((m) => ({ run: m.goGenerator, sample: m.goSample })), }, // php emits a standalone full PHP SDK (curl extension) — no TypeScript involved. + // Exceptions ARE its error mode, so `result` has no PHP rendering. php: { + errorModes: ['throw'], + notApplicable: LANGUAGE_SDK_NOT_APPLICABLE, load: () => import('./php/index.js').then((m) => ({ run: m.phpGenerator, sample: m.phpSample })), }, @@ -84,7 +105,10 @@ export const BUILTIN_META: Record = { export function validateSelection( names: string[], emit: EmitOptions, - registry: Map | GeneratorDescriptor> + registry: Map | GeneratorDescriptor>, + // `outputMode` travels beside `emit` in the generator input, so the caller passes it + // in for the not-applicable check; absent means the caller left it at the default. + outputMode?: OutputMode ): void { const selected = new Set(names); const errorMode = emit.errorMode ?? 'throw'; @@ -118,5 +142,13 @@ export function validateSelection( `The "${name}" generator does not support runtime "${runtime}" (supported: ${descriptor.runtimes.join(', ')}).` ); } + // An option this generator can't apply is announced, not silently dropped. Only an + // EXPLICIT value warns — defaults would nag every run. + const chosen: Record = { ...emit, outputMode }; + for (const [option, reason] of Object.entries(descriptor.notApplicable ?? {})) { + if (chosen[option] !== undefined) { + logger.warn(`generate-client: the "${name}" generator ignores ${option} — ${reason}.\n`); + } + } } } diff --git a/packages/client-generator/src/generators/types.ts b/packages/client-generator/src/generators/types.ts index 62935a8186..ccae85b17a 100644 --- a/packages/client-generator/src/generators/types.ts +++ b/packages/client-generator/src/generators/types.ts @@ -82,6 +82,13 @@ export type GeneratorDescriptor = { dateTypes?: DateType[]; /** Runtime modes this generator supports; absent = compatible with both. */ runtimes?: ('inline' | 'package')[]; + /** + * Options this generator does not apply, mapped to the reason it doesn't. Setting + * one explicitly warns instead of being silently dropped — a global option + * (`outputMode`, `runtime`, …) may be meaningful for one selected generator and + * meaningless for another, so this informs rather than rejects. + */ + notApplicable?: Partial>; }; /** diff --git a/packages/client-generator/src/pipeline.ts b/packages/client-generator/src/pipeline.ts index 9185edc8da..94e08684f6 100644 --- a/packages/client-generator/src/pipeline.ts +++ b/packages/client-generator/src/pipeline.ts @@ -189,8 +189,9 @@ export async function generateClient( pagination: options.pagination, }; // Fail fast on an incompatible selection (missing prerequisite, unsupported - // error-mode/date-type/runtime) before producing any file. - validateSelection(selected, emit, registry); + // error-mode/date-type/runtime) before producing any file, and warn about options a + // selected generator can't apply. + validateSelection(selected, emit, registry, options.outputMode); const files = runGenerators(model, { outputPath, outputMode: options.outputMode ?? 'single', From ee6eaa498a51c8802a4999cd860b9286df4d5279 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 5 Aug 2026 14:33:23 +0300 Subject: [PATCH 083/211] feat: support dateType: Date in the python, go, and php generators --- docs/@v2/configuration/reference/client.md | 2 +- docs/@v2/guides/use-generated-client.md | 2 +- .../client-generator/go-runtime/runtime.go | 31 ++++ .../python-runtime/_decode.py | 20 +++ .../src/authoring/__tests__/exports.test.ts | 4 + .../client-generator/src/authoring/index.ts | 1 + .../client-generator/src/authoring/options.ts | 12 ++ .../src/emitters/go-runtime-sources.ts | 2 +- .../src/emitters/python-runtime-sources.ts | 2 +- .../client-generator/src/emitters/types.ts | 12 +- .../src/generators/__tests__/go.test.ts | 100 ++++++++++++ .../src/generators/__tests__/php.test.ts | 83 ++++++++++ .../src/generators/__tests__/python.test.ts | 118 ++++++++++++++ .../src/generators/go/AGENTS.md | 6 +- .../src/generators/go/index.ts | 82 +++++++--- .../src/generators/php/AGENTS.md | 7 +- .../src/generators/php/index.ts | 150 +++++++++++++----- .../src/generators/python/AGENTS.md | 3 + .../src/generators/python/index.ts | 73 ++++++--- .../generators/php.AGENTS.md | 7 +- 20 files changed, 617 insertions(+), 100 deletions(-) create mode 100644 packages/client-generator/src/authoring/options.ts diff --git a/docs/@v2/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md index 8e2b66c334..05ae2118dd 100644 --- a/docs/@v2/configuration/reference/client.md +++ b/docs/@v2/configuration/reference/client.md @@ -25,7 +25,7 @@ For runs without a configuration file, declare pagination per operation with the | `importExt` | string | Extension in generated relative imports: `js` (default, for tsc and bundlers) or `ts` (for Node's built-in type stripping). TypeScript output only. | | `argsStyle` | string | How operation inputs are passed: `flat` or `grouped`. 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 are throw-only (their language idiom IS the error mode) and reject `result`. | -| `dateType` | string | Type of `date`/`date-time` fields: `string` or `Date`. | +| `dateType` | string | Type of `date`/`date-time` fields: `string` or `Date`. Every language honors it — `Date` in TypeScript, `datetime`/`date` in Python, `time.Time`/`Date` in Go, `DateTimeImmutable` in PHP. | | `mockData` | string | Data mode for the `mock` generator: `static` or `faker`. | | `mockSeed` | number | Seed for `faker`-mode mocks. | | `queryKeyPrefix` | string | Leading element for every `tanstack-query` query/mutation key — namespaces the cache when several generated APIs share one QueryClient. Config-only, no flag. | diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index 81aa84ff04..7866857b10 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -60,7 +60,7 @@ To ship it as a real bin, compile with `tsc` and point `package.json`'s `bin` at The `python` generator emits a self-contained `.py` next to the configured output — a full Python SDK over [httpx](https://www.python-httpx.org/) (`pip install httpx`, Python ≥ 3.9): typed dataclass models (allOf flattened, enums, discriminated unions decoded by their discriminator), a `Client` and an `AsyncClient` with one method per operation, auth, retries with `Retry-After` and jittered backoff, timeouts, idempotency keys, middleware hooks, pagination iterators (`_pages()` / `_items()`, `async for` variants), SSE streaming, multipart bodies, `_with_headers()` envelope variants for operations that declare response headers, and a `Servers` class for templated server URLs. -`errorMode` maps to raising `ApiError` (default) or returning a `Result` dataclass. +`errorMode` maps to raising `ApiError` (default) or returning a `Result` dataclass, and `dateType: Date` yields `datetime`/`date` objects. No TypeScript is involved: generating with only `python` selected does not require the `typescript` package. ```python diff --git a/packages/client-generator/go-runtime/runtime.go b/packages/client-generator/go-runtime/runtime.go index 5f43623f4d..5fcfba3627 100644 --- a/packages/client-generator/go-runtime/runtime.go +++ b/packages/client-generator/go-runtime/runtime.go @@ -84,6 +84,37 @@ type Middleware struct { OnResponse func(resp *http.Response) } +// Date is an RFC 3339 full-date — a calendar date with no time component. Fields +// typed `date` under `dateType: Date` use it because encoding/json speaks only +// RFC 3339 date-time for time.Time, which a bare "2006-01-02" fails to satisfy. +type Date struct { + time.Time +} + +const dateLayout = "2006-01-02" + +// UnmarshalJSON parses a "2006-01-02" string; an empty string leaves the zero value. +func (d *Date) UnmarshalJSON(data []byte) error { + var raw string + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if raw == "" { + return nil + } + parsed, err := time.Parse(dateLayout, raw) + if err != nil { + return err + } + d.Time = parsed + return nil +} + +// MarshalJSON writes the date back without a time component. +func (d Date) MarshalJSON() ([]byte, error) { + return json.Marshal(d.Format(dateLayout)) +} + // Config is the per-client configuration shared by every operation method. type Config struct { ServerURL string diff --git a/packages/client-generator/python-runtime/_decode.py b/packages/client-generator/python-runtime/_decode.py index 54880c105b..1270399063 100644 --- a/packages/client-generator/python-runtime/_decode.py +++ b/packages/client-generator/python-runtime/_decode.py @@ -7,6 +7,7 @@ import dataclasses import typing +from datetime import date, datetime from enum import Enum from typing import Any, Dict, Tuple, get_args, get_origin, get_type_hints @@ -51,6 +52,20 @@ def decode(type_: Any, data: Any): return data if isinstance(type_, type) and issubclass(type_, Enum): return type_(data) + # `dateType: Date` annotates date/date-time fields as datetime objects; a value that + # doesn't parse passes through unchanged (the server is the source of truth). + if type_ is datetime or type_ is date: + if not isinstance(data, str): + return data + try: + # `datetime` accepts a bare date too; `date` rejects a timestamp, so trim it. + return ( + datetime.fromisoformat(data) + if type_ is datetime + else date.fromisoformat(data[:10]) + ) + except ValueError: + return data if dataclasses.is_dataclass(type_): hints = get_type_hints(type_) field_map = getattr(type_, "_field_map", {}) @@ -76,6 +91,11 @@ def encode(value: Any): return out if isinstance(value, Enum): return value.value + # A date-only value must not gain a time component on the way out. + if isinstance(value, datetime): + return value.isoformat() + if isinstance(value, date): + return value.isoformat() if isinstance(value, list): return [encode(item) for item in value] if isinstance(value, dict): diff --git a/packages/client-generator/src/authoring/__tests__/exports.test.ts b/packages/client-generator/src/authoring/__tests__/exports.test.ts index fbbf68073e..41c1e7b6e5 100644 --- a/packages/client-generator/src/authoring/__tests__/exports.test.ts +++ b/packages/client-generator/src/authoring/__tests__/exports.test.ts @@ -1,6 +1,10 @@ import * as root from '../../index.js'; import { AUTHORING_HELPER_NAMES } from '../index.js'; +// The /generate entry pulls in the whole emitter graph on first import, which can +// exceed the 5s default on a loaded machine. +vi.setConfig({ testTimeout: 60_000 }); + describe('authoring toolkit exports', () => { it('exports every helper from the package root (the TS-free entry)', () => { for (const name of AUTHORING_HELPER_NAMES) { diff --git a/packages/client-generator/src/authoring/index.ts b/packages/client-generator/src/authoring/index.ts index ebb711c979..397eb3e755 100644 --- a/packages/client-generator/src/authoring/index.ts +++ b/packages/client-generator/src/authoring/index.ts @@ -3,6 +3,7 @@ // from the package ROOT: a custom generator importing only these stays TS-free. export { Printer } from './printer.js'; +export type { DateType } from './options.js'; export { casing, identifierFor, RESERVED_WORDS } from './naming.js'; export { paginationRuleFor, type NeutralPaginationRule } from './pagination.js'; export { diff --git a/packages/client-generator/src/authoring/options.ts b/packages/client-generator/src/authoring/options.ts new file mode 100644 index 0000000000..7005fabecf --- /dev/null +++ b/packages/client-generator/src/authoring/options.ts @@ -0,0 +1,12 @@ +// Neutral option types every generator (any output language) may need to honor. +// They live in the authoring toolkit — not the TypeScript emitters — so a language +// generator can type its plumbing without importing TS-specific modules. + +/** + * How `format: date-time`/`date` string fields are typed: + * - `'string'` (default): the wire shape — an ISO string. + * - `'Date'`: the target language's date object (`Date` in TypeScript, `datetime` + * in Python, `time.Time` in Go, `DateTimeImmutable` in PHP). The generated + * client converts on the wire boundary, so the values match the types. + */ +export type DateType = 'string' | 'Date'; diff --git a/packages/client-generator/src/emitters/go-runtime-sources.ts b/packages/client-generator/src/emitters/go-runtime-sources.ts index fcd1b488de..fb634cfdae 100644 --- a/packages/client-generator/src/emitters/go-runtime-sources.ts +++ b/packages/client-generator/src/emitters/go-runtime-sources.ts @@ -1,3 +1,3 @@ // GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`. export const GO_RUNTIME_SOURCE = - '// Package client — the embedded runtime for generated Go SDKs. Hand-authored\n// once and stitched into every generated client (see\n// scripts/generate-runtime-sources.mjs), semantically in lockstep with the\n// TypeScript runtime: auth OR-alternatives, a retry loop with Retry-After and\n// full-jitter backoff, per-attempt timeouts, idempotency keys, and middleware\n// hooks. Standard library only — a generated Go SDK has zero dependencies.\npackage client\n\nimport (\n\t"bytes"\n\t"context"\n\t"encoding/base64"\n\t"encoding/json"\n\t"errors"\n\t"fmt"\n\t"io"\n\t"math/rand"\n\t"mime/multipart"\n\t"net/http"\n\t"net/url"\n\t"strconv"\n\t"strings"\n\t"time"\n)\n\n// APIError is returned for a non-2xx response, carrying the decoded error body.\ntype APIError struct {\n\tURL string\n\tStatus int\n\tStatusText string\n\tBody any\n}\n\nfunc (e *APIError) Error() string {\n\treturn fmt.Sprintf("request failed with status %d", e.Status)\n}\n\n// TimeoutError is returned when a request attempt exceeds the configured\n// timeout — carrying the context a log line needs.\ntype TimeoutError struct {\n\tOperationID string\n\tTimeout time.Duration\n\tAttempt int\n}\n\nfunc (e *TimeoutError) Error() string {\n\treturn fmt.Sprintf("request %q timed out after %s (attempt %d)", e.OperationID, e.Timeout, e.Attempt)\n}\n\n// SecuritySpec mirrors the descriptor table\'s security entries.\ntype SecuritySpec struct {\n\tScheme string\n\tKind string // "bearer" | "basic" | "apiKey"\n\tName string // header/query/cookie name for apiKey\n\tIn string // "header" | "query" | "cookie"\n}\n\n// Auth holds the client credentials; zero value = anonymous.\ntype Auth struct {\n\tBearer func() string\n\tBasic *BasicAuth\n\tAPIKey map[string]func() string\n}\n\ntype BasicAuth struct {\n\tUsername string\n\tPassword string\n}\n\n// RetryConfig mirrors the TypeScript runtime\'s retry policy knobs.\ntype RetryConfig struct {\n\tRetries int\n\tRetryDelay time.Duration // base; default 1s\n\tRetryStrategy string // "" (exponential) | "fixed"\n\tNoJitter bool\n\t// RetryOn fully replaces the default predicate when set.\n\tRetryOn func(attempt int, resp *http.Response, err error) bool\n}\n\n// Middleware hooks run around every request (OnRequest before serialization order\n// is N/A in Go — bodies are values; OnResponse runs in reverse registration order).\ntype Middleware struct {\n\tOnRequest func(req *http.Request)\n\tOnResponse func(resp *http.Response)\n}\n\n// Config is the per-client configuration shared by every operation method.\ntype Config struct {\n\tServerURL string\n\tHTTPClient *http.Client\n\tHeaders map[string]string\n\tTimeout time.Duration\n\tRetry RetryConfig\n\tMiddleware []Middleware\n\tIdempotencyKey func() string\n\tAuth Auth\n}\n\nfunc resolveToken(provider func() string) string {\n\tif provider == nil {\n\t\treturn ""\n\t}\n\treturn provider()\n}\n\nfunc schemeConfigured(spec SecuritySpec, auth Auth) bool {\n\tswitch spec.Kind {\n\tcase "apiKey":\n\t\t_, ok := auth.APIKey[spec.Scheme]\n\t\treturn ok\n\tcase "bearer":\n\t\treturn auth.Bearer != nil\n\tdefault:\n\t\treturn auth.Basic != nil\n\t}\n}\n\n// resolveAuth applies the first fully-configured OR-alternative; when none is,\n// the first alternative\'s configured schemes are still sent (the server rejects\n// the request — same behavior as the TypeScript runtime).\nfunc resolveAuth(security [][]SecuritySpec, auth Auth) (map[string]string, url.Values) {\n\theaders := map[string]string{}\n\tquery := url.Values{}\n\tif len(security) == 0 {\n\t\treturn headers, query\n\t}\n\talternative := security[0]\n\tfor _, candidate := range security {\n\t\tall := true\n\t\tfor _, spec := range candidate {\n\t\t\tif !schemeConfigured(spec, auth) {\n\t\t\t\tall = false\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif all {\n\t\t\talternative = candidate\n\t\t\tbreak\n\t\t}\n\t}\n\tvar cookies []string\n\tfor _, spec := range alternative {\n\t\tswitch spec.Kind {\n\t\tcase "apiKey":\n\t\t\tprovider, ok := auth.APIKey[spec.Scheme]\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalue := resolveToken(provider)\n\t\t\tswitch spec.In {\n\t\t\tcase "query":\n\t\t\t\tquery.Set(spec.Name, value)\n\t\t\tcase "cookie":\n\t\t\t\tcookies = append(cookies, spec.Name+"="+url.QueryEscape(value))\n\t\t\tdefault:\n\t\t\t\theaders[spec.Name] = value\n\t\t\t}\n\t\tcase "bearer":\n\t\t\tif auth.Bearer != nil {\n\t\t\t\theaders["Authorization"] = "Bearer " + resolveToken(auth.Bearer)\n\t\t\t}\n\t\tdefault:\n\t\t\tif auth.Basic != nil {\n\t\t\t\ttoken := base64.StdEncoding.EncodeToString([]byte(auth.Basic.Username + ":" + auth.Basic.Password))\n\t\t\t\theaders["Authorization"] = "Basic " + token\n\t\t\t}\n\t\t}\n\t}\n\tif len(cookies) > 0 {\n\t\theaders["Cookie"] = strings.Join(cookies, "; ")\n\t}\n\treturn headers, query\n}\n\n// buildURL substitutes {param} path placeholders with percent-encoded values.\nfunc buildURL(serverURL, path string, pathParams map[string]string) string {\n\tfilled := path\n\tfor name, value := range pathParams {\n\t\tfilled = strings.ReplaceAll(filled, "{"+name+"}", url.PathEscape(value))\n\t}\n\treturn strings.TrimRight(serverURL, "/") + filled\n}\n\nvar transientStatus = map[int]bool{408: true, 429: true, 500: true, 502: true, 503: true, 504: true}\n\nfunc defaultRetryOn(method string, headers map[string]string, resp *http.Response, err error) bool {\n\tsafe := false\n\tswitch strings.ToUpper(method) {\n\tcase "GET", "HEAD", "PUT", "DELETE", "OPTIONS":\n\t\tsafe = true\n\t}\n\tif _, ok := headers["Idempotency-Key"]; ok {\n\t\tsafe = true\n\t}\n\tif !safe {\n\t\treturn false\n\t}\n\tif err != nil {\n\t\treturn true\n\t}\n\treturn resp != nil && transientStatus[resp.StatusCode]\n}\n\nfunc retryDelay(retry RetryConfig, attempt int, retryAfter string) time.Duration {\n\tif retryAfter != "" {\n\t\tif seconds, err := strconv.ParseFloat(retryAfter, 64); err == nil {\n\t\t\treturn time.Duration(seconds * float64(time.Second))\n\t\t}\n\t}\n\tbase := retry.RetryDelay\n\tif base == 0 {\n\t\tbase = time.Second\n\t}\n\traw := base\n\tif retry.RetryStrategy != "fixed" {\n\t\traw = base * time.Duration(1<<(attempt-1))\n\t}\n\tif retry.NoJitter {\n\t\treturn raw\n\t}\n\treturn time.Duration(rand.Int63n(int64(raw) + 1))\n}\n\ntype requestSpec struct {\n\tOperationID string\n\tMethod string\n\tURL string\n\tHeaders map[string]string\n\tQuery url.Values\n\tBody io.Reader\n\tContentType string\n\tTimeout time.Duration\n\tRetry *RetryConfig\n\tIdempotencyKey string\n\t// bodyBytes is retained so retries can replay the body.\n\tbodyBytes []byte\n}\n\n// send is the request core: header merge, idempotency keys, the retry loop\n// (fresh timeout budget per attempt), and the middleware onion.\nfunc send(ctx context.Context, config *Config, spec requestSpec) (*http.Response, error) {\n\tretry := config.Retry\n\tif spec.Retry != nil {\n\t\tretry = *spec.Retry\n\t}\n\ttimeout := config.Timeout\n\tif spec.Timeout != 0 {\n\t\ttimeout = spec.Timeout\n\t}\n\theaders := map[string]string{}\n\tfor key, value := range config.Headers {\n\t\theaders[key] = value\n\t}\n\tfor key, value := range spec.Headers {\n\t\theaders[key] = value\n\t}\n\tmethod := strings.ToUpper(spec.Method)\n\tif (method == "POST" || method == "PATCH") && headers["Idempotency-Key"] == "" {\n\t\tif spec.IdempotencyKey != "" {\n\t\t\theaders["Idempotency-Key"] = spec.IdempotencyKey\n\t\t} else if config.IdempotencyKey != nil {\n\t\t\theaders["Idempotency-Key"] = config.IdempotencyKey()\n\t\t}\n\t}\n\thttpClient := config.HTTPClient\n\tif httpClient == nil {\n\t\thttpClient = http.DefaultClient\n\t}\n\tif spec.Body != nil {\n\t\tpayload, err := io.ReadAll(spec.Body)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tspec.bodyBytes = payload\n\t}\n\tfullURL := spec.URL\n\tif len(spec.Query) > 0 {\n\t\tseparator := "?"\n\t\tif strings.Contains(fullURL, "?") {\n\t\t\tseparator = "&"\n\t\t}\n\t\tfullURL += separator + spec.Query.Encode()\n\t}\n\tmaxAttempts := 1 + retry.Retries\n\tfor attempt := 1; ; attempt++ {\n\t\tattemptCtx := ctx\n\t\tvar cancel context.CancelFunc\n\t\tif timeout > 0 {\n\t\t\tattemptCtx, cancel = context.WithTimeout(ctx, timeout)\n\t\t}\n\t\tvar bodyReader io.Reader\n\t\tif spec.bodyBytes != nil {\n\t\t\tbodyReader = bytes.NewReader(spec.bodyBytes)\n\t\t}\n\t\treq, err := http.NewRequestWithContext(attemptCtx, method, fullURL, bodyReader)\n\t\tif err != nil {\n\t\t\tif cancel != nil {\n\t\t\t\tcancel()\n\t\t\t}\n\t\t\treturn nil, err\n\t\t}\n\t\tfor key, value := range headers {\n\t\t\treq.Header.Set(key, value)\n\t\t}\n\t\tif spec.ContentType != "" && spec.bodyBytes != nil {\n\t\t\treq.Header.Set("Content-Type", spec.ContentType)\n\t\t}\n\t\tfor _, mw := range config.Middleware {\n\t\t\tif mw.OnRequest != nil {\n\t\t\t\tmw.OnRequest(req)\n\t\t\t}\n\t\t}\n\t\tresp, err := httpClient.Do(req)\n\t\tshouldRetry := retry.RetryOn\n\t\tretryable := false\n\t\tif shouldRetry != nil {\n\t\t\tretryable = shouldRetry(attempt, resp, err)\n\t\t} else {\n\t\t\tretryable = defaultRetryOn(method, headers, resp, err)\n\t\t}\n\t\tif err != nil {\n\t\t\tif cancel != nil {\n\t\t\t\tcancel()\n\t\t\t}\n\t\t\ttimedOut := errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil\n\t\t\tif attempt < maxAttempts && retryable {\n\t\t\t\ttime.Sleep(retryDelay(retry, attempt, ""))\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif timedOut {\n\t\t\t\treturn nil, &TimeoutError{OperationID: spec.OperationID, Timeout: timeout, Attempt: attempt}\n\t\t\t}\n\t\t\treturn nil, err\n\t\t}\n\t\tfor i := len(config.Middleware) - 1; i >= 0; i-- {\n\t\t\tif config.Middleware[i].OnResponse != nil {\n\t\t\t\tconfig.Middleware[i].OnResponse(resp)\n\t\t\t}\n\t\t}\n\t\tif resp.StatusCode >= 400 && attempt < maxAttempts && retryable {\n\t\t\tafter := resp.Header.Get("Retry-After")\n\t\t\tio.Copy(io.Discard, resp.Body)\n\t\t\tresp.Body.Close()\n\t\t\tif cancel != nil {\n\t\t\t\tcancel()\n\t\t\t}\n\t\t\ttime.Sleep(retryDelay(retry, attempt, after))\n\t\t\tcontinue\n\t\t}\n\t\t// The response body outlives this call; tie the attempt context\'s lifetime to it.\n\t\tif cancel != nil {\n\t\t\tresp.Body = &cancelOnClose{ReadCloser: resp.Body, cancel: cancel}\n\t\t}\n\t\treturn resp, nil\n\t}\n}\n\ntype cancelOnClose struct {\n\tio.ReadCloser\n\tcancel context.CancelFunc\n}\n\nfunc (c *cancelOnClose) Close() error {\n\tc.cancel()\n\treturn c.ReadCloser.Close()\n}\n\n// decodeJSON decodes a response body into target; a nil target drains and closes.\nfunc decodeJSON(resp *http.Response, target any) error {\n\tdefer resp.Body.Close()\n\tif target == nil {\n\t\t_, err := io.Copy(io.Discard, resp.Body)\n\t\treturn err\n\t}\n\treturn json.NewDecoder(resp.Body).Decode(target)\n}\n\n// headerString returns the named response header, or nil when absent.\nfunc headerString(header http.Header, name string) *string {\n\tvalue := header.Get(name)\n\tif value == "" {\n\t\treturn nil\n\t}\n\treturn &value\n}\n\n// headerInt64 parses the named header as an integer; nil when absent or unparsable.\nfunc headerInt64(header http.Header, name string) *int64 {\n\traw := strings.TrimSpace(header.Get(name))\n\tif raw == "" {\n\t\treturn nil\n\t}\n\tvalue, err := strconv.ParseInt(raw, 10, 64)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn &value\n}\n\n// headerFloat64 parses the named header as a number; nil when absent or unparsable.\nfunc headerFloat64(header http.Header, name string) *float64 {\n\traw := strings.TrimSpace(header.Get(name))\n\tif raw == "" {\n\t\treturn nil\n\t}\n\tvalue, err := strconv.ParseFloat(raw, 64)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn &value\n}\n\n// headerBool parses a `true`/`false` header; nil when absent or anything else.\nfunc headerBool(header http.Header, name string) *bool {\n\traw := strings.ToLower(strings.TrimSpace(header.Get(name)))\n\tif raw != "true" && raw != "false" {\n\t\treturn nil\n\t}\n\tvalue := raw == "true"\n\treturn &value\n}\n\n// apiErrorFrom builds the structured error for a non-2xx response.\nfunc apiErrorFrom(resp *http.Response, requestURL string) error {\n\tdefer resp.Body.Close()\n\tvar body any\n\tdata, _ := io.ReadAll(resp.Body)\n\tif len(data) > 0 {\n\t\tif err := json.Unmarshal(data, &body); err != nil {\n\t\t\tbody = string(data)\n\t\t}\n\t}\n\treturn &APIError{URL: requestURL, Status: resp.StatusCode, StatusText: resp.Status, Body: body}\n}\n\n// ─── Pagination ───\n\n// PaginationSpec mirrors the descriptor table\'s pagination entries.\ntype PaginationSpec struct {\n\tStyle string\n\tParam string\n\tNextCursor string\n\tHasMore string\n\tLimitParam string\n\tItems string\n}\n\n// resolvePointer walks an RFC 6901 JSON pointer over decoded JSON; nil on any miss.\nfunc resolvePointer(data any, pointer string) any {\n\tif pointer == "" {\n\t\treturn data\n\t}\n\tif !strings.HasPrefix(pointer, "/") {\n\t\treturn nil\n\t}\n\tcurrent := data\n\tfor _, token := range strings.Split(pointer[1:], "/") {\n\t\tkey := strings.ReplaceAll(strings.ReplaceAll(token, "~1", "/"), "~0", "~")\n\t\tswitch typed := current.(type) {\n\t\tcase map[string]any:\n\t\t\tcurrent = typed[key]\n\t\tcase []any:\n\t\t\tindex, err := strconv.Atoi(key)\n\t\t\tif err != nil || index < 0 || index >= len(typed) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tcurrent = typed[index]\n\t\tdefault:\n\t\t\treturn nil\n\t\t}\n\t\tif current == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn current\n}\n\n// reencode converts decoded JSON (maps/slices) into a typed value via a JSON round-trip.\nfunc reencode(raw any, target any) error {\n\tdata, err := json.Marshal(raw)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn json.Unmarshal(data, target)\n}\n\ntype pageCall func(params url.Values) (any, *http.Response, error)\n\n// iterPages yields raw page JSON per the pagination spec — the same stop\n// conditions and infinite-loop guards as the TypeScript runtime. The returned\n// function is a range-over-func iterator (Go 1.23+) and plainly callable before that.\nfunc iterPages(call pageCall, spec PaginationSpec, base url.Values) func(yield func(any, error) bool) {\n\treturn func(yield func(any, error) bool) {\n\t\tswitch spec.Style {\n\t\tcase "cursor":\n\t\t\tvar cursor any\n\t\t\tif values, ok := base[spec.Param]; ok && len(values) > 0 {\n\t\t\t\tcursor = values[0]\n\t\t\t}\n\t\t\tfor {\n\t\t\t\tparams := cloneValues(base)\n\t\t\t\tif cursor != nil {\n\t\t\t\t\tparams.Set(spec.Param, fmt.Sprint(cursor))\n\t\t\t\t}\n\t\t\t\tpage, _, err := call(params)\n\t\t\t\tif err != nil {\n\t\t\t\t\tyield(nil, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif !yield(page, nil) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif spec.HasMore != "" {\n\t\t\t\t\tif more, ok := resolvePointer(page, spec.HasMore).(bool); ok && !more {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tnext := resolvePointer(page, spec.NextCursor)\n\t\t\t\tif next == nil || next == "" {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tswitch next.(type) {\n\t\t\t\tcase string, float64:\n\t\t\t\tdefault:\n\t\t\t\t\tyield(nil, fmt.Errorf("pagination cursor at %s is not a string or number", spec.NextCursor))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif cursor != nil && fmt.Sprint(next) == fmt.Sprint(cursor) {\n\t\t\t\t\tyield(nil, errors.New("pagination did not advance: the operation returned the same cursor twice"))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tcursor = next\n\t\t\t}\n\t\tcase "link":\n\t\t\tparams := cloneValues(base)\n\t\t\tprevious := ""\n\t\t\tfor {\n\t\t\t\tpage, resp, err := call(params)\n\t\t\t\tif err != nil {\n\t\t\t\t\tyield(nil, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif !yield(page, nil) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\ttarget := linkNext(resp.Header.Get("Link"))\n\t\t\t\tif target == "" {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tpageURL := ""\n\t\t\t\tif resp.Request != nil && resp.Request.URL != nil {\n\t\t\t\t\tpageURL = resp.Request.URL.String()\n\t\t\t\t}\n\t\t\t\tbaseURL, err := url.Parse(pageURL)\n\t\t\t\tif err != nil || pageURL == "" {\n\t\t\t\t\tbaseURL, _ = url.Parse("http://relative.invalid")\n\t\t\t\t}\n\t\t\t\ttargetURL, err := baseURL.Parse(target)\n\t\t\t\tif err != nil {\n\t\t\t\t\tyield(nil, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tnext := targetURL.String()\n\t\t\t\tif next == previous || next == pageURL {\n\t\t\t\t\tyield(nil, errors.New(`pagination did not advance: the Link rel="next" target repeats`))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tprevious = next\n\t\t\t\tparams = cloneValues(base)\n\t\t\t\tfor key, values := range targetURL.Query() {\n\t\t\t\t\tfor _, value := range values {\n\t\t\t\t\t\tparams.Add(key, value)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\tdefault: // offset / page\n\t\t\tposition := 0\n\t\t\tif spec.Style == "page" {\n\t\t\t\tposition = 1\n\t\t\t}\n\t\t\tif values, ok := base[spec.Param]; ok && len(values) > 0 && values[0] != "" {\n\t\t\t\tif parsed, err := strconv.Atoi(values[0]); err == nil {\n\t\t\t\t\tposition = parsed\n\t\t\t\t}\n\t\t\t}\n\t\t\tpreviousItems := ""\n\t\t\tfor {\n\t\t\t\tparams := cloneValues(base)\n\t\t\t\tparams.Set(spec.Param, strconv.Itoa(position))\n\t\t\t\tpage, _, err := call(params)\n\t\t\t\tif err != nil {\n\t\t\t\t\tyield(nil, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\titems, _ := resolvePointer(page, spec.Items).([]any)\n\t\t\t\tserialized := ""\n\t\t\t\tif items != nil {\n\t\t\t\t\tserialized = fmt.Sprint(items)\n\t\t\t\t\tif serialized == previousItems {\n\t\t\t\t\t\tyield(nil, errors.New("pagination did not advance: the operation returned the same page twice"))\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif !yield(page, nil) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif len(items) == 0 {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tpreviousItems = serialized\n\t\t\t\tif spec.Style == "page" {\n\t\t\t\t\tposition++\n\t\t\t\t} else {\n\t\t\t\t\tposition += len(items)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc cloneValues(values url.Values) url.Values {\n\tout := url.Values{}\n\tfor key, entries := range values {\n\t\tfor _, entry := range entries {\n\t\t\tout.Add(key, entry)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc linkNext(header string) string {\n\tif header == "" {\n\t\treturn ""\n\t}\n\tfor _, entry := range strings.Split(header, ",") {\n\t\tparts := strings.Split(entry, ";")\n\t\tif len(parts) < 2 {\n\t\t\tcontinue\n\t\t}\n\t\ttarget := strings.TrimSpace(parts[0])\n\t\tif !strings.HasPrefix(target, "<") || !strings.HasSuffix(target, ">") {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, param := range parts[1:] {\n\t\t\ttrimmed := strings.TrimSpace(param)\n\t\t\tif strings.HasPrefix(trimmed, "rel=") {\n\t\t\t\trel := strings.Trim(strings.TrimPrefix(trimmed, "rel="), `"`)\n\t\t\t\tfor _, kind := range strings.Fields(rel) {\n\t\t\t\t\tif kind == "next" {\n\t\t\t\t\t\treturn strings.Trim(target, "<>")\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn ""\n}\n\n// ─── Server-Sent Events ───\n\n// ServerSentEvent is one decoded event; Data is the raw text (or parsed JSON\n// for operations that declare a JSON event stream).\ntype ServerSentEvent struct {\n\tEvent string\n\tData any\n\tID string\n\tRetry int\n}\n\nfunc parseSSEFrame(raw string, jsonData bool) (ServerSentEvent, bool, error) {\n\tevent := ServerSentEvent{Retry: -1}\n\tsawField := false\n\tvar dataLines []string\n\tnormalized := strings.ReplaceAll(strings.ReplaceAll(raw, "\\r\\n", "\\n"), "\\r", "\\n")\n\tfor _, line := range strings.Split(normalized, "\\n") {\n\t\tif line == "" || strings.HasPrefix(line, ":") {\n\t\t\tcontinue\n\t\t}\n\t\tfield, value, _ := strings.Cut(line, ":")\n\t\tvalue = strings.TrimPrefix(value, " ")\n\t\tsawField = true\n\t\tswitch field {\n\t\tcase "event":\n\t\t\tevent.Event = value\n\t\tcase "data":\n\t\t\tdataLines = append(dataLines, value)\n\t\tcase "id":\n\t\t\tevent.ID = value\n\t\tcase "retry":\n\t\t\tif parsed, err := strconv.Atoi(value); err == nil && parsed >= 0 && value != "" {\n\t\t\t\tevent.Retry = parsed\n\t\t\t}\n\t\t}\n\t}\n\tif !sawField {\n\t\treturn event, false, nil\n\t}\n\ttext := strings.Join(dataLines, "\\n")\n\tevent.Data = text\n\tif jsonData && text != "" {\n\t\tvar parsed any\n\t\tif err := json.Unmarshal([]byte(text), &parsed); err != nil {\n\t\t\treturn event, false, err\n\t\t}\n\t\tevent.Data = parsed\n\t}\n\treturn event, true, nil\n}\n\n// iterSSE streams events, reconnecting on dropped connections with Last-Event-ID\n// (a fresh open call = fresh auth); a 4xx/5xx or a bad JSON payload is definitive.\nfunc iterSSE(open func(extraHeaders map[string]string) (*http.Response, error), jsonData bool) func(yield func(ServerSentEvent, error) bool) {\n\treturn func(yield func(ServerSentEvent, error) bool) {\n\t\tlastEventID := ""\n\t\tserverRetry := -1\n\t\tfailures := 0\n\t\tfor {\n\t\t\theaders := map[string]string{"Accept": "text/event-stream"}\n\t\t\tif lastEventID != "" {\n\t\t\t\theaders["Last-Event-ID"] = lastEventID\n\t\t\t}\n\t\t\tresp, err := open(headers)\n\t\t\tif err == nil && resp.StatusCode >= 400 {\n\t\t\t\tyield(ServerSentEvent{}, apiErrorFrom(resp, ""))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err == nil {\n\t\t\t\tfailures = 0\n\t\t\t\tbuffer := ""\n\t\t\t\tchunk := make([]byte, 4096)\n\t\t\t\tclean := false\n\t\t\t\tfor {\n\t\t\t\t\tn, readErr := resp.Body.Read(chunk)\n\t\t\t\t\tbuffer += string(chunk[:n])\n\t\t\t\t\tfor {\n\t\t\t\t\t\tframe, rest, found := strings.Cut(buffer, "\\n\\n")\n\t\t\t\t\t\tif !found {\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t\tbuffer = rest\n\t\t\t\t\t\tevent, ok, parseErr := parseSSEFrame(frame, jsonData)\n\t\t\t\t\t\tif parseErr != nil {\n\t\t\t\t\t\t\tresp.Body.Close()\n\t\t\t\t\t\t\tyield(ServerSentEvent{}, parseErr)\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif ok {\n\t\t\t\t\t\t\tif event.ID != "" {\n\t\t\t\t\t\t\t\tlastEventID = event.ID\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif event.Retry >= 0 {\n\t\t\t\t\t\t\t\tserverRetry = event.Retry\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif !yield(event, nil) {\n\t\t\t\t\t\t\t\tresp.Body.Close()\n\t\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif readErr == io.EOF {\n\t\t\t\t\t\tclean = true\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\tif readErr != nil {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tresp.Body.Close()\n\t\t\t\tif clean {\n\t\t\t\t\tif strings.TrimSpace(buffer) != "" {\n\t\t\t\t\t\tif event, ok, parseErr := parseSSEFrame(buffer, jsonData); parseErr == nil && ok {\n\t\t\t\t\t\t\tyield(event, nil)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tfailures++\n\t\t\tbase := time.Second\n\t\t\tif serverRetry >= 0 {\n\t\t\t\tbase = time.Duration(serverRetry) * time.Millisecond\n\t\t\t}\n\t\t\tdelay := base * time.Duration(1<<(failures-1))\n\t\t\tif delay > 30*time.Second {\n\t\t\t\tdelay = 30 * time.Second\n\t\t\t}\n\t\t\ttime.Sleep(time.Duration(rand.Int63n(int64(delay) + 1)))\n\t\t}\n\t}\n}\n\n// ─── Multipart ───\n\n// toMultipart splits a typed body into a multipart/form-data payload: []byte\n// values upload as file parts, everything else as form fields (nested values\n// JSON-encoded) — mirroring the TypeScript runtime\'s FormData serialization.\nfunc toMultipart(body any) (string, io.Reader, error) {\n\tvar wire map[string]any\n\tif err := reencode(body, &wire); err != nil {\n\t\treturn "", nil, err\n\t}\n\tbuffer := &bytes.Buffer{}\n\twriter := multipart.NewWriter(buffer)\n\tfor key, value := range wire {\n\t\tswitch typed := value.(type) {\n\t\tcase string:\n\t\t\tif err := writer.WriteField(key, typed); err != nil {\n\t\t\t\treturn "", nil, err\n\t\t\t}\n\t\tcase float64, bool:\n\t\t\tif err := writer.WriteField(key, fmt.Sprint(typed)); err != nil {\n\t\t\t\treturn "", nil, err\n\t\t\t}\n\t\tdefault:\n\t\t\tencoded, err := json.Marshal(typed)\n\t\t\tif err != nil {\n\t\t\t\treturn "", nil, err\n\t\t\t}\n\t\t\tif err := writer.WriteField(key, string(encoded)); err != nil {\n\t\t\t\treturn "", nil, err\n\t\t\t}\n\t\t}\n\t}\n\tif err := writer.Close(); err != nil {\n\t\treturn "", nil, err\n\t}\n\treturn writer.FormDataContentType(), buffer, nil\n}\n'; + '// Package client — the embedded runtime for generated Go SDKs. Hand-authored\n// once and stitched into every generated client (see\n// scripts/generate-runtime-sources.mjs), semantically in lockstep with the\n// TypeScript runtime: auth OR-alternatives, a retry loop with Retry-After and\n// full-jitter backoff, per-attempt timeouts, idempotency keys, and middleware\n// hooks. Standard library only — a generated Go SDK has zero dependencies.\npackage client\n\nimport (\n\t"bytes"\n\t"context"\n\t"encoding/base64"\n\t"encoding/json"\n\t"errors"\n\t"fmt"\n\t"io"\n\t"math/rand"\n\t"mime/multipart"\n\t"net/http"\n\t"net/url"\n\t"strconv"\n\t"strings"\n\t"time"\n)\n\n// APIError is returned for a non-2xx response, carrying the decoded error body.\ntype APIError struct {\n\tURL string\n\tStatus int\n\tStatusText string\n\tBody any\n}\n\nfunc (e *APIError) Error() string {\n\treturn fmt.Sprintf("request failed with status %d", e.Status)\n}\n\n// TimeoutError is returned when a request attempt exceeds the configured\n// timeout — carrying the context a log line needs.\ntype TimeoutError struct {\n\tOperationID string\n\tTimeout time.Duration\n\tAttempt int\n}\n\nfunc (e *TimeoutError) Error() string {\n\treturn fmt.Sprintf("request %q timed out after %s (attempt %d)", e.OperationID, e.Timeout, e.Attempt)\n}\n\n// SecuritySpec mirrors the descriptor table\'s security entries.\ntype SecuritySpec struct {\n\tScheme string\n\tKind string // "bearer" | "basic" | "apiKey"\n\tName string // header/query/cookie name for apiKey\n\tIn string // "header" | "query" | "cookie"\n}\n\n// Auth holds the client credentials; zero value = anonymous.\ntype Auth struct {\n\tBearer func() string\n\tBasic *BasicAuth\n\tAPIKey map[string]func() string\n}\n\ntype BasicAuth struct {\n\tUsername string\n\tPassword string\n}\n\n// RetryConfig mirrors the TypeScript runtime\'s retry policy knobs.\ntype RetryConfig struct {\n\tRetries int\n\tRetryDelay time.Duration // base; default 1s\n\tRetryStrategy string // "" (exponential) | "fixed"\n\tNoJitter bool\n\t// RetryOn fully replaces the default predicate when set.\n\tRetryOn func(attempt int, resp *http.Response, err error) bool\n}\n\n// Middleware hooks run around every request (OnRequest before serialization order\n// is N/A in Go — bodies are values; OnResponse runs in reverse registration order).\ntype Middleware struct {\n\tOnRequest func(req *http.Request)\n\tOnResponse func(resp *http.Response)\n}\n\n// Date is an RFC 3339 full-date — a calendar date with no time component. Fields\n// typed `date` under `dateType: Date` use it because encoding/json speaks only\n// RFC 3339 date-time for time.Time, which a bare "2006-01-02" fails to satisfy.\ntype Date struct {\n\ttime.Time\n}\n\nconst dateLayout = "2006-01-02"\n\n// UnmarshalJSON parses a "2006-01-02" string; an empty string leaves the zero value.\nfunc (d *Date) UnmarshalJSON(data []byte) error {\n\tvar raw string\n\tif err := json.Unmarshal(data, &raw); err != nil {\n\t\treturn err\n\t}\n\tif raw == "" {\n\t\treturn nil\n\t}\n\tparsed, err := time.Parse(dateLayout, raw)\n\tif err != nil {\n\t\treturn err\n\t}\n\td.Time = parsed\n\treturn nil\n}\n\n// MarshalJSON writes the date back without a time component.\nfunc (d Date) MarshalJSON() ([]byte, error) {\n\treturn json.Marshal(d.Format(dateLayout))\n}\n\n// Config is the per-client configuration shared by every operation method.\ntype Config struct {\n\tServerURL string\n\tHTTPClient *http.Client\n\tHeaders map[string]string\n\tTimeout time.Duration\n\tRetry RetryConfig\n\tMiddleware []Middleware\n\tIdempotencyKey func() string\n\tAuth Auth\n}\n\nfunc resolveToken(provider func() string) string {\n\tif provider == nil {\n\t\treturn ""\n\t}\n\treturn provider()\n}\n\nfunc schemeConfigured(spec SecuritySpec, auth Auth) bool {\n\tswitch spec.Kind {\n\tcase "apiKey":\n\t\t_, ok := auth.APIKey[spec.Scheme]\n\t\treturn ok\n\tcase "bearer":\n\t\treturn auth.Bearer != nil\n\tdefault:\n\t\treturn auth.Basic != nil\n\t}\n}\n\n// resolveAuth applies the first fully-configured OR-alternative; when none is,\n// the first alternative\'s configured schemes are still sent (the server rejects\n// the request — same behavior as the TypeScript runtime).\nfunc resolveAuth(security [][]SecuritySpec, auth Auth) (map[string]string, url.Values) {\n\theaders := map[string]string{}\n\tquery := url.Values{}\n\tif len(security) == 0 {\n\t\treturn headers, query\n\t}\n\talternative := security[0]\n\tfor _, candidate := range security {\n\t\tall := true\n\t\tfor _, spec := range candidate {\n\t\t\tif !schemeConfigured(spec, auth) {\n\t\t\t\tall = false\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif all {\n\t\t\talternative = candidate\n\t\t\tbreak\n\t\t}\n\t}\n\tvar cookies []string\n\tfor _, spec := range alternative {\n\t\tswitch spec.Kind {\n\t\tcase "apiKey":\n\t\t\tprovider, ok := auth.APIKey[spec.Scheme]\n\t\t\tif !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tvalue := resolveToken(provider)\n\t\t\tswitch spec.In {\n\t\t\tcase "query":\n\t\t\t\tquery.Set(spec.Name, value)\n\t\t\tcase "cookie":\n\t\t\t\tcookies = append(cookies, spec.Name+"="+url.QueryEscape(value))\n\t\t\tdefault:\n\t\t\t\theaders[spec.Name] = value\n\t\t\t}\n\t\tcase "bearer":\n\t\t\tif auth.Bearer != nil {\n\t\t\t\theaders["Authorization"] = "Bearer " + resolveToken(auth.Bearer)\n\t\t\t}\n\t\tdefault:\n\t\t\tif auth.Basic != nil {\n\t\t\t\ttoken := base64.StdEncoding.EncodeToString([]byte(auth.Basic.Username + ":" + auth.Basic.Password))\n\t\t\t\theaders["Authorization"] = "Basic " + token\n\t\t\t}\n\t\t}\n\t}\n\tif len(cookies) > 0 {\n\t\theaders["Cookie"] = strings.Join(cookies, "; ")\n\t}\n\treturn headers, query\n}\n\n// buildURL substitutes {param} path placeholders with percent-encoded values.\nfunc buildURL(serverURL, path string, pathParams map[string]string) string {\n\tfilled := path\n\tfor name, value := range pathParams {\n\t\tfilled = strings.ReplaceAll(filled, "{"+name+"}", url.PathEscape(value))\n\t}\n\treturn strings.TrimRight(serverURL, "/") + filled\n}\n\nvar transientStatus = map[int]bool{408: true, 429: true, 500: true, 502: true, 503: true, 504: true}\n\nfunc defaultRetryOn(method string, headers map[string]string, resp *http.Response, err error) bool {\n\tsafe := false\n\tswitch strings.ToUpper(method) {\n\tcase "GET", "HEAD", "PUT", "DELETE", "OPTIONS":\n\t\tsafe = true\n\t}\n\tif _, ok := headers["Idempotency-Key"]; ok {\n\t\tsafe = true\n\t}\n\tif !safe {\n\t\treturn false\n\t}\n\tif err != nil {\n\t\treturn true\n\t}\n\treturn resp != nil && transientStatus[resp.StatusCode]\n}\n\nfunc retryDelay(retry RetryConfig, attempt int, retryAfter string) time.Duration {\n\tif retryAfter != "" {\n\t\tif seconds, err := strconv.ParseFloat(retryAfter, 64); err == nil {\n\t\t\treturn time.Duration(seconds * float64(time.Second))\n\t\t}\n\t}\n\tbase := retry.RetryDelay\n\tif base == 0 {\n\t\tbase = time.Second\n\t}\n\traw := base\n\tif retry.RetryStrategy != "fixed" {\n\t\traw = base * time.Duration(1<<(attempt-1))\n\t}\n\tif retry.NoJitter {\n\t\treturn raw\n\t}\n\treturn time.Duration(rand.Int63n(int64(raw) + 1))\n}\n\ntype requestSpec struct {\n\tOperationID string\n\tMethod string\n\tURL string\n\tHeaders map[string]string\n\tQuery url.Values\n\tBody io.Reader\n\tContentType string\n\tTimeout time.Duration\n\tRetry *RetryConfig\n\tIdempotencyKey string\n\t// bodyBytes is retained so retries can replay the body.\n\tbodyBytes []byte\n}\n\n// send is the request core: header merge, idempotency keys, the retry loop\n// (fresh timeout budget per attempt), and the middleware onion.\nfunc send(ctx context.Context, config *Config, spec requestSpec) (*http.Response, error) {\n\tretry := config.Retry\n\tif spec.Retry != nil {\n\t\tretry = *spec.Retry\n\t}\n\ttimeout := config.Timeout\n\tif spec.Timeout != 0 {\n\t\ttimeout = spec.Timeout\n\t}\n\theaders := map[string]string{}\n\tfor key, value := range config.Headers {\n\t\theaders[key] = value\n\t}\n\tfor key, value := range spec.Headers {\n\t\theaders[key] = value\n\t}\n\tmethod := strings.ToUpper(spec.Method)\n\tif (method == "POST" || method == "PATCH") && headers["Idempotency-Key"] == "" {\n\t\tif spec.IdempotencyKey != "" {\n\t\t\theaders["Idempotency-Key"] = spec.IdempotencyKey\n\t\t} else if config.IdempotencyKey != nil {\n\t\t\theaders["Idempotency-Key"] = config.IdempotencyKey()\n\t\t}\n\t}\n\thttpClient := config.HTTPClient\n\tif httpClient == nil {\n\t\thttpClient = http.DefaultClient\n\t}\n\tif spec.Body != nil {\n\t\tpayload, err := io.ReadAll(spec.Body)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tspec.bodyBytes = payload\n\t}\n\tfullURL := spec.URL\n\tif len(spec.Query) > 0 {\n\t\tseparator := "?"\n\t\tif strings.Contains(fullURL, "?") {\n\t\t\tseparator = "&"\n\t\t}\n\t\tfullURL += separator + spec.Query.Encode()\n\t}\n\tmaxAttempts := 1 + retry.Retries\n\tfor attempt := 1; ; attempt++ {\n\t\tattemptCtx := ctx\n\t\tvar cancel context.CancelFunc\n\t\tif timeout > 0 {\n\t\t\tattemptCtx, cancel = context.WithTimeout(ctx, timeout)\n\t\t}\n\t\tvar bodyReader io.Reader\n\t\tif spec.bodyBytes != nil {\n\t\t\tbodyReader = bytes.NewReader(spec.bodyBytes)\n\t\t}\n\t\treq, err := http.NewRequestWithContext(attemptCtx, method, fullURL, bodyReader)\n\t\tif err != nil {\n\t\t\tif cancel != nil {\n\t\t\t\tcancel()\n\t\t\t}\n\t\t\treturn nil, err\n\t\t}\n\t\tfor key, value := range headers {\n\t\t\treq.Header.Set(key, value)\n\t\t}\n\t\tif spec.ContentType != "" && spec.bodyBytes != nil {\n\t\t\treq.Header.Set("Content-Type", spec.ContentType)\n\t\t}\n\t\tfor _, mw := range config.Middleware {\n\t\t\tif mw.OnRequest != nil {\n\t\t\t\tmw.OnRequest(req)\n\t\t\t}\n\t\t}\n\t\tresp, err := httpClient.Do(req)\n\t\tshouldRetry := retry.RetryOn\n\t\tretryable := false\n\t\tif shouldRetry != nil {\n\t\t\tretryable = shouldRetry(attempt, resp, err)\n\t\t} else {\n\t\t\tretryable = defaultRetryOn(method, headers, resp, err)\n\t\t}\n\t\tif err != nil {\n\t\t\tif cancel != nil {\n\t\t\t\tcancel()\n\t\t\t}\n\t\t\ttimedOut := errors.Is(err, context.DeadlineExceeded) && ctx.Err() == nil\n\t\t\tif attempt < maxAttempts && retryable {\n\t\t\t\ttime.Sleep(retryDelay(retry, attempt, ""))\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif timedOut {\n\t\t\t\treturn nil, &TimeoutError{OperationID: spec.OperationID, Timeout: timeout, Attempt: attempt}\n\t\t\t}\n\t\t\treturn nil, err\n\t\t}\n\t\tfor i := len(config.Middleware) - 1; i >= 0; i-- {\n\t\t\tif config.Middleware[i].OnResponse != nil {\n\t\t\t\tconfig.Middleware[i].OnResponse(resp)\n\t\t\t}\n\t\t}\n\t\tif resp.StatusCode >= 400 && attempt < maxAttempts && retryable {\n\t\t\tafter := resp.Header.Get("Retry-After")\n\t\t\tio.Copy(io.Discard, resp.Body)\n\t\t\tresp.Body.Close()\n\t\t\tif cancel != nil {\n\t\t\t\tcancel()\n\t\t\t}\n\t\t\ttime.Sleep(retryDelay(retry, attempt, after))\n\t\t\tcontinue\n\t\t}\n\t\t// The response body outlives this call; tie the attempt context\'s lifetime to it.\n\t\tif cancel != nil {\n\t\t\tresp.Body = &cancelOnClose{ReadCloser: resp.Body, cancel: cancel}\n\t\t}\n\t\treturn resp, nil\n\t}\n}\n\ntype cancelOnClose struct {\n\tio.ReadCloser\n\tcancel context.CancelFunc\n}\n\nfunc (c *cancelOnClose) Close() error {\n\tc.cancel()\n\treturn c.ReadCloser.Close()\n}\n\n// decodeJSON decodes a response body into target; a nil target drains and closes.\nfunc decodeJSON(resp *http.Response, target any) error {\n\tdefer resp.Body.Close()\n\tif target == nil {\n\t\t_, err := io.Copy(io.Discard, resp.Body)\n\t\treturn err\n\t}\n\treturn json.NewDecoder(resp.Body).Decode(target)\n}\n\n// headerString returns the named response header, or nil when absent.\nfunc headerString(header http.Header, name string) *string {\n\tvalue := header.Get(name)\n\tif value == "" {\n\t\treturn nil\n\t}\n\treturn &value\n}\n\n// headerInt64 parses the named header as an integer; nil when absent or unparsable.\nfunc headerInt64(header http.Header, name string) *int64 {\n\traw := strings.TrimSpace(header.Get(name))\n\tif raw == "" {\n\t\treturn nil\n\t}\n\tvalue, err := strconv.ParseInt(raw, 10, 64)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn &value\n}\n\n// headerFloat64 parses the named header as a number; nil when absent or unparsable.\nfunc headerFloat64(header http.Header, name string) *float64 {\n\traw := strings.TrimSpace(header.Get(name))\n\tif raw == "" {\n\t\treturn nil\n\t}\n\tvalue, err := strconv.ParseFloat(raw, 64)\n\tif err != nil {\n\t\treturn nil\n\t}\n\treturn &value\n}\n\n// headerBool parses a `true`/`false` header; nil when absent or anything else.\nfunc headerBool(header http.Header, name string) *bool {\n\traw := strings.ToLower(strings.TrimSpace(header.Get(name)))\n\tif raw != "true" && raw != "false" {\n\t\treturn nil\n\t}\n\tvalue := raw == "true"\n\treturn &value\n}\n\n// apiErrorFrom builds the structured error for a non-2xx response.\nfunc apiErrorFrom(resp *http.Response, requestURL string) error {\n\tdefer resp.Body.Close()\n\tvar body any\n\tdata, _ := io.ReadAll(resp.Body)\n\tif len(data) > 0 {\n\t\tif err := json.Unmarshal(data, &body); err != nil {\n\t\t\tbody = string(data)\n\t\t}\n\t}\n\treturn &APIError{URL: requestURL, Status: resp.StatusCode, StatusText: resp.Status, Body: body}\n}\n\n// ─── Pagination ───\n\n// PaginationSpec mirrors the descriptor table\'s pagination entries.\ntype PaginationSpec struct {\n\tStyle string\n\tParam string\n\tNextCursor string\n\tHasMore string\n\tLimitParam string\n\tItems string\n}\n\n// resolvePointer walks an RFC 6901 JSON pointer over decoded JSON; nil on any miss.\nfunc resolvePointer(data any, pointer string) any {\n\tif pointer == "" {\n\t\treturn data\n\t}\n\tif !strings.HasPrefix(pointer, "/") {\n\t\treturn nil\n\t}\n\tcurrent := data\n\tfor _, token := range strings.Split(pointer[1:], "/") {\n\t\tkey := strings.ReplaceAll(strings.ReplaceAll(token, "~1", "/"), "~0", "~")\n\t\tswitch typed := current.(type) {\n\t\tcase map[string]any:\n\t\t\tcurrent = typed[key]\n\t\tcase []any:\n\t\t\tindex, err := strconv.Atoi(key)\n\t\t\tif err != nil || index < 0 || index >= len(typed) {\n\t\t\t\treturn nil\n\t\t\t}\n\t\t\tcurrent = typed[index]\n\t\tdefault:\n\t\t\treturn nil\n\t\t}\n\t\tif current == nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\treturn current\n}\n\n// reencode converts decoded JSON (maps/slices) into a typed value via a JSON round-trip.\nfunc reencode(raw any, target any) error {\n\tdata, err := json.Marshal(raw)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn json.Unmarshal(data, target)\n}\n\ntype pageCall func(params url.Values) (any, *http.Response, error)\n\n// iterPages yields raw page JSON per the pagination spec — the same stop\n// conditions and infinite-loop guards as the TypeScript runtime. The returned\n// function is a range-over-func iterator (Go 1.23+) and plainly callable before that.\nfunc iterPages(call pageCall, spec PaginationSpec, base url.Values) func(yield func(any, error) bool) {\n\treturn func(yield func(any, error) bool) {\n\t\tswitch spec.Style {\n\t\tcase "cursor":\n\t\t\tvar cursor any\n\t\t\tif values, ok := base[spec.Param]; ok && len(values) > 0 {\n\t\t\t\tcursor = values[0]\n\t\t\t}\n\t\t\tfor {\n\t\t\t\tparams := cloneValues(base)\n\t\t\t\tif cursor != nil {\n\t\t\t\t\tparams.Set(spec.Param, fmt.Sprint(cursor))\n\t\t\t\t}\n\t\t\t\tpage, _, err := call(params)\n\t\t\t\tif err != nil {\n\t\t\t\t\tyield(nil, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif !yield(page, nil) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif spec.HasMore != "" {\n\t\t\t\t\tif more, ok := resolvePointer(page, spec.HasMore).(bool); ok && !more {\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tnext := resolvePointer(page, spec.NextCursor)\n\t\t\t\tif next == nil || next == "" {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tswitch next.(type) {\n\t\t\t\tcase string, float64:\n\t\t\t\tdefault:\n\t\t\t\t\tyield(nil, fmt.Errorf("pagination cursor at %s is not a string or number", spec.NextCursor))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif cursor != nil && fmt.Sprint(next) == fmt.Sprint(cursor) {\n\t\t\t\t\tyield(nil, errors.New("pagination did not advance: the operation returned the same cursor twice"))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tcursor = next\n\t\t\t}\n\t\tcase "link":\n\t\t\tparams := cloneValues(base)\n\t\t\tprevious := ""\n\t\t\tfor {\n\t\t\t\tpage, resp, err := call(params)\n\t\t\t\tif err != nil {\n\t\t\t\t\tyield(nil, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif !yield(page, nil) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\ttarget := linkNext(resp.Header.Get("Link"))\n\t\t\t\tif target == "" {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tpageURL := ""\n\t\t\t\tif resp.Request != nil && resp.Request.URL != nil {\n\t\t\t\t\tpageURL = resp.Request.URL.String()\n\t\t\t\t}\n\t\t\t\tbaseURL, err := url.Parse(pageURL)\n\t\t\t\tif err != nil || pageURL == "" {\n\t\t\t\t\tbaseURL, _ = url.Parse("http://relative.invalid")\n\t\t\t\t}\n\t\t\t\ttargetURL, err := baseURL.Parse(target)\n\t\t\t\tif err != nil {\n\t\t\t\t\tyield(nil, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tnext := targetURL.String()\n\t\t\t\tif next == previous || next == pageURL {\n\t\t\t\t\tyield(nil, errors.New(`pagination did not advance: the Link rel="next" target repeats`))\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tprevious = next\n\t\t\t\tparams = cloneValues(base)\n\t\t\t\tfor key, values := range targetURL.Query() {\n\t\t\t\t\tfor _, value := range values {\n\t\t\t\t\t\tparams.Add(key, value)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\tdefault: // offset / page\n\t\t\tposition := 0\n\t\t\tif spec.Style == "page" {\n\t\t\t\tposition = 1\n\t\t\t}\n\t\t\tif values, ok := base[spec.Param]; ok && len(values) > 0 && values[0] != "" {\n\t\t\t\tif parsed, err := strconv.Atoi(values[0]); err == nil {\n\t\t\t\t\tposition = parsed\n\t\t\t\t}\n\t\t\t}\n\t\t\tpreviousItems := ""\n\t\t\tfor {\n\t\t\t\tparams := cloneValues(base)\n\t\t\t\tparams.Set(spec.Param, strconv.Itoa(position))\n\t\t\t\tpage, _, err := call(params)\n\t\t\t\tif err != nil {\n\t\t\t\t\tyield(nil, err)\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\titems, _ := resolvePointer(page, spec.Items).([]any)\n\t\t\t\tserialized := ""\n\t\t\t\tif items != nil {\n\t\t\t\t\tserialized = fmt.Sprint(items)\n\t\t\t\t\tif serialized == previousItems {\n\t\t\t\t\t\tyield(nil, errors.New("pagination did not advance: the operation returned the same page twice"))\n\t\t\t\t\t\treturn\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tif !yield(page, nil) {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tif len(items) == 0 {\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t\tpreviousItems = serialized\n\t\t\t\tif spec.Style == "page" {\n\t\t\t\t\tposition++\n\t\t\t\t} else {\n\t\t\t\t\tposition += len(items)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc cloneValues(values url.Values) url.Values {\n\tout := url.Values{}\n\tfor key, entries := range values {\n\t\tfor _, entry := range entries {\n\t\t\tout.Add(key, entry)\n\t\t}\n\t}\n\treturn out\n}\n\nfunc linkNext(header string) string {\n\tif header == "" {\n\t\treturn ""\n\t}\n\tfor _, entry := range strings.Split(header, ",") {\n\t\tparts := strings.Split(entry, ";")\n\t\tif len(parts) < 2 {\n\t\t\tcontinue\n\t\t}\n\t\ttarget := strings.TrimSpace(parts[0])\n\t\tif !strings.HasPrefix(target, "<") || !strings.HasSuffix(target, ">") {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, param := range parts[1:] {\n\t\t\ttrimmed := strings.TrimSpace(param)\n\t\t\tif strings.HasPrefix(trimmed, "rel=") {\n\t\t\t\trel := strings.Trim(strings.TrimPrefix(trimmed, "rel="), `"`)\n\t\t\t\tfor _, kind := range strings.Fields(rel) {\n\t\t\t\t\tif kind == "next" {\n\t\t\t\t\t\treturn strings.Trim(target, "<>")\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\treturn ""\n}\n\n// ─── Server-Sent Events ───\n\n// ServerSentEvent is one decoded event; Data is the raw text (or parsed JSON\n// for operations that declare a JSON event stream).\ntype ServerSentEvent struct {\n\tEvent string\n\tData any\n\tID string\n\tRetry int\n}\n\nfunc parseSSEFrame(raw string, jsonData bool) (ServerSentEvent, bool, error) {\n\tevent := ServerSentEvent{Retry: -1}\n\tsawField := false\n\tvar dataLines []string\n\tnormalized := strings.ReplaceAll(strings.ReplaceAll(raw, "\\r\\n", "\\n"), "\\r", "\\n")\n\tfor _, line := range strings.Split(normalized, "\\n") {\n\t\tif line == "" || strings.HasPrefix(line, ":") {\n\t\t\tcontinue\n\t\t}\n\t\tfield, value, _ := strings.Cut(line, ":")\n\t\tvalue = strings.TrimPrefix(value, " ")\n\t\tsawField = true\n\t\tswitch field {\n\t\tcase "event":\n\t\t\tevent.Event = value\n\t\tcase "data":\n\t\t\tdataLines = append(dataLines, value)\n\t\tcase "id":\n\t\t\tevent.ID = value\n\t\tcase "retry":\n\t\t\tif parsed, err := strconv.Atoi(value); err == nil && parsed >= 0 && value != "" {\n\t\t\t\tevent.Retry = parsed\n\t\t\t}\n\t\t}\n\t}\n\tif !sawField {\n\t\treturn event, false, nil\n\t}\n\ttext := strings.Join(dataLines, "\\n")\n\tevent.Data = text\n\tif jsonData && text != "" {\n\t\tvar parsed any\n\t\tif err := json.Unmarshal([]byte(text), &parsed); err != nil {\n\t\t\treturn event, false, err\n\t\t}\n\t\tevent.Data = parsed\n\t}\n\treturn event, true, nil\n}\n\n// iterSSE streams events, reconnecting on dropped connections with Last-Event-ID\n// (a fresh open call = fresh auth); a 4xx/5xx or a bad JSON payload is definitive.\nfunc iterSSE(open func(extraHeaders map[string]string) (*http.Response, error), jsonData bool) func(yield func(ServerSentEvent, error) bool) {\n\treturn func(yield func(ServerSentEvent, error) bool) {\n\t\tlastEventID := ""\n\t\tserverRetry := -1\n\t\tfailures := 0\n\t\tfor {\n\t\t\theaders := map[string]string{"Accept": "text/event-stream"}\n\t\t\tif lastEventID != "" {\n\t\t\t\theaders["Last-Event-ID"] = lastEventID\n\t\t\t}\n\t\t\tresp, err := open(headers)\n\t\t\tif err == nil && resp.StatusCode >= 400 {\n\t\t\t\tyield(ServerSentEvent{}, apiErrorFrom(resp, ""))\n\t\t\t\treturn\n\t\t\t}\n\t\t\tif err == nil {\n\t\t\t\tfailures = 0\n\t\t\t\tbuffer := ""\n\t\t\t\tchunk := make([]byte, 4096)\n\t\t\t\tclean := false\n\t\t\t\tfor {\n\t\t\t\t\tn, readErr := resp.Body.Read(chunk)\n\t\t\t\t\tbuffer += string(chunk[:n])\n\t\t\t\t\tfor {\n\t\t\t\t\t\tframe, rest, found := strings.Cut(buffer, "\\n\\n")\n\t\t\t\t\t\tif !found {\n\t\t\t\t\t\t\tbreak\n\t\t\t\t\t\t}\n\t\t\t\t\t\tbuffer = rest\n\t\t\t\t\t\tevent, ok, parseErr := parseSSEFrame(frame, jsonData)\n\t\t\t\t\t\tif parseErr != nil {\n\t\t\t\t\t\t\tresp.Body.Close()\n\t\t\t\t\t\t\tyield(ServerSentEvent{}, parseErr)\n\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t}\n\t\t\t\t\t\tif ok {\n\t\t\t\t\t\t\tif event.ID != "" {\n\t\t\t\t\t\t\t\tlastEventID = event.ID\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif event.Retry >= 0 {\n\t\t\t\t\t\t\t\tserverRetry = event.Retry\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tif !yield(event, nil) {\n\t\t\t\t\t\t\t\tresp.Body.Close()\n\t\t\t\t\t\t\t\treturn\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tif readErr == io.EOF {\n\t\t\t\t\t\tclean = true\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t\tif readErr != nil {\n\t\t\t\t\t\tbreak\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tresp.Body.Close()\n\t\t\t\tif clean {\n\t\t\t\t\tif strings.TrimSpace(buffer) != "" {\n\t\t\t\t\t\tif event, ok, parseErr := parseSSEFrame(buffer, jsonData); parseErr == nil && ok {\n\t\t\t\t\t\t\tyield(event, nil)\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t\tfailures++\n\t\t\tbase := time.Second\n\t\t\tif serverRetry >= 0 {\n\t\t\t\tbase = time.Duration(serverRetry) * time.Millisecond\n\t\t\t}\n\t\t\tdelay := base * time.Duration(1<<(failures-1))\n\t\t\tif delay > 30*time.Second {\n\t\t\t\tdelay = 30 * time.Second\n\t\t\t}\n\t\t\ttime.Sleep(time.Duration(rand.Int63n(int64(delay) + 1)))\n\t\t}\n\t}\n}\n\n// ─── Multipart ───\n\n// toMultipart splits a typed body into a multipart/form-data payload: []byte\n// values upload as file parts, everything else as form fields (nested values\n// JSON-encoded) — mirroring the TypeScript runtime\'s FormData serialization.\nfunc toMultipart(body any) (string, io.Reader, error) {\n\tvar wire map[string]any\n\tif err := reencode(body, &wire); err != nil {\n\t\treturn "", nil, err\n\t}\n\tbuffer := &bytes.Buffer{}\n\twriter := multipart.NewWriter(buffer)\n\tfor key, value := range wire {\n\t\tswitch typed := value.(type) {\n\t\tcase string:\n\t\t\tif err := writer.WriteField(key, typed); err != nil {\n\t\t\t\treturn "", nil, err\n\t\t\t}\n\t\tcase float64, bool:\n\t\t\tif err := writer.WriteField(key, fmt.Sprint(typed)); err != nil {\n\t\t\t\treturn "", nil, err\n\t\t\t}\n\t\tdefault:\n\t\t\tencoded, err := json.Marshal(typed)\n\t\t\tif err != nil {\n\t\t\t\treturn "", nil, err\n\t\t\t}\n\t\t\tif err := writer.WriteField(key, string(encoded)); err != nil {\n\t\t\t\treturn "", nil, err\n\t\t\t}\n\t\t}\n\t}\n\tif err := writer.Close(); err != nil {\n\t\treturn "", nil, err\n\t}\n\treturn writer.FormDataContentType(), buffer, nil\n}\n'; diff --git a/packages/client-generator/src/emitters/python-runtime-sources.ts b/packages/client-generator/src/emitters/python-runtime-sources.ts index 2135d3ff1d..2f92525479 100644 --- a/packages/client-generator/src/emitters/python-runtime-sources.ts +++ b/packages/client-generator/src/emitters/python-runtime-sources.ts @@ -7,7 +7,7 @@ export const PYTHON_RUNTIME_SOURCES = { '_url.py': '# URL assembly for generated Python clients — path-parameter substitution with\n# percent-encoding, mirroring the TypeScript runtime\'s url.ts semantics.\nfrom __future__ import annotations\n\nfrom typing import Any, Dict\nfrom urllib.parse import quote\n\n\ndef build_url(server_url: str, path: str, path_params: Dict[str, Any]) -> str:\n filled = path\n for name, value in path_params.items():\n filled = filled.replace("{" + name + "}", quote(str(value), safe=""))\n return server_url.rstrip("/") + filled\n', '_decode.py': - '# Reflective JSON <-> dataclass conversion for generated Python clients. The\n# generated models are plain dataclasses; this decoder hydrates parsed JSON into\n# them (and encode() mirrors back to wire shape), honoring each class\'s\n# `_field_map` (python name -> wire name) and typing constructs the generator\n# emits: Optional/Union, List, Dict, Enum, Literal, Any.\nfrom __future__ import annotations\n\nimport dataclasses\nimport typing\nfrom enum import Enum\nfrom typing import Any, Dict, Tuple, get_args, get_origin, get_type_hints\n\n# Discriminated unions: resolved Union annotation -> (wire property, {value: class}).\n# The generated module registers its unions here; decode() dispatches through it\n# before falling back to trying members in order.\nDISCRIMINATORS: Dict[Any, Tuple[str, Dict[str, Any]]] = {}\n\n\ndef decode(type_: Any, data: Any):\n """Best-effort hydration: wire data -> the annotated Python shape. Unknown or\n mismatched shapes pass through unchanged (the server is the source of truth)."""\n if data is None or type_ is Any or type_ is None:\n return data\n origin = get_origin(type_)\n if origin is typing.Union:\n discriminator = DISCRIMINATORS.get(type_)\n if discriminator is not None and isinstance(data, dict):\n wire_property, mapping = discriminator\n target = mapping.get(data.get(wire_property))\n if target is not None:\n try:\n return decode(target, data)\n except (TypeError, ValueError, KeyError):\n pass\n for member in get_args(type_):\n if member is type(None):\n continue\n try:\n return decode(member, data)\n except (TypeError, ValueError, KeyError):\n continue\n return data\n if origin is list:\n (item_type,) = get_args(type_) or (Any,)\n return [decode(item_type, item) for item in data]\n if origin is dict:\n args = get_args(type_)\n value_type = args[1] if len(args) == 2 else Any\n return {key: decode(value_type, value) for key, value in data.items()}\n if origin is typing.Literal:\n return data\n if isinstance(type_, type) and issubclass(type_, Enum):\n return type_(data)\n if dataclasses.is_dataclass(type_):\n hints = get_type_hints(type_)\n field_map = getattr(type_, "_field_map", {})\n kwargs = {}\n for field in dataclasses.fields(type_):\n wire = field_map.get(field.name, field.name)\n if isinstance(data, dict) and wire in data:\n kwargs[field.name] = decode(hints.get(field.name, Any), data[wire])\n return type_(**kwargs)\n return data\n\n\ndef encode(value: Any):\n """Python shape -> wire (JSON) shape; inverse of decode for request bodies."""\n if dataclasses.is_dataclass(value) and not isinstance(value, type):\n field_map = getattr(type(value), "_field_map", {})\n out = {}\n for field in dataclasses.fields(value):\n item = getattr(value, field.name)\n if item is None:\n continue\n out[field_map.get(field.name, field.name)] = encode(item)\n return out\n if isinstance(value, Enum):\n return value.value\n if isinstance(value, list):\n return [encode(item) for item in value]\n if isinstance(value, dict):\n return {key: encode(item) for key, item in value.items()}\n return value\n', + '# Reflective JSON <-> dataclass conversion for generated Python clients. The\n# generated models are plain dataclasses; this decoder hydrates parsed JSON into\n# them (and encode() mirrors back to wire shape), honoring each class\'s\n# `_field_map` (python name -> wire name) and typing constructs the generator\n# emits: Optional/Union, List, Dict, Enum, Literal, Any.\nfrom __future__ import annotations\n\nimport dataclasses\nimport typing\nfrom datetime import date, datetime\nfrom enum import Enum\nfrom typing import Any, Dict, Tuple, get_args, get_origin, get_type_hints\n\n# Discriminated unions: resolved Union annotation -> (wire property, {value: class}).\n# The generated module registers its unions here; decode() dispatches through it\n# before falling back to trying members in order.\nDISCRIMINATORS: Dict[Any, Tuple[str, Dict[str, Any]]] = {}\n\n\ndef decode(type_: Any, data: Any):\n """Best-effort hydration: wire data -> the annotated Python shape. Unknown or\n mismatched shapes pass through unchanged (the server is the source of truth)."""\n if data is None or type_ is Any or type_ is None:\n return data\n origin = get_origin(type_)\n if origin is typing.Union:\n discriminator = DISCRIMINATORS.get(type_)\n if discriminator is not None and isinstance(data, dict):\n wire_property, mapping = discriminator\n target = mapping.get(data.get(wire_property))\n if target is not None:\n try:\n return decode(target, data)\n except (TypeError, ValueError, KeyError):\n pass\n for member in get_args(type_):\n if member is type(None):\n continue\n try:\n return decode(member, data)\n except (TypeError, ValueError, KeyError):\n continue\n return data\n if origin is list:\n (item_type,) = get_args(type_) or (Any,)\n return [decode(item_type, item) for item in data]\n if origin is dict:\n args = get_args(type_)\n value_type = args[1] if len(args) == 2 else Any\n return {key: decode(value_type, value) for key, value in data.items()}\n if origin is typing.Literal:\n return data\n if isinstance(type_, type) and issubclass(type_, Enum):\n return type_(data)\n # `dateType: Date` annotates date/date-time fields as datetime objects; a value that\n # doesn\'t parse passes through unchanged (the server is the source of truth).\n if type_ is datetime or type_ is date:\n if not isinstance(data, str):\n return data\n try:\n # `datetime` accepts a bare date too; `date` rejects a timestamp, so trim it.\n return (\n datetime.fromisoformat(data)\n if type_ is datetime\n else date.fromisoformat(data[:10])\n )\n except ValueError:\n return data\n if dataclasses.is_dataclass(type_):\n hints = get_type_hints(type_)\n field_map = getattr(type_, "_field_map", {})\n kwargs = {}\n for field in dataclasses.fields(type_):\n wire = field_map.get(field.name, field.name)\n if isinstance(data, dict) and wire in data:\n kwargs[field.name] = decode(hints.get(field.name, Any), data[wire])\n return type_(**kwargs)\n return data\n\n\ndef encode(value: Any):\n """Python shape -> wire (JSON) shape; inverse of decode for request bodies."""\n if dataclasses.is_dataclass(value) and not isinstance(value, type):\n field_map = getattr(type(value), "_field_map", {})\n out = {}\n for field in dataclasses.fields(value):\n item = getattr(value, field.name)\n if item is None:\n continue\n out[field_map.get(field.name, field.name)] = encode(item)\n return out\n if isinstance(value, Enum):\n return value.value\n # A date-only value must not gain a time component on the way out.\n if isinstance(value, datetime):\n return value.isoformat()\n if isinstance(value, date):\n return value.isoformat()\n if isinstance(value, list):\n return [encode(item) for item in value]\n if isinstance(value, dict):\n return {key: encode(item) for key, item in value.items()}\n return value\n', '_send.py': '# The request core for generated Python clients — mirror of the TypeScript\n# runtime\'s send.ts: default + config + per-call headers, on_request middleware\n# BEFORE serialization (mutations are sent), the retry loop (idempotent-methods\n# default, Idempotency-Key opt-in makes POST/PATCH safe, Retry-After honored,\n# exponential backoff with full jitter, a fresh timeout budget per attempt), and\n# the reverse on_response onion.\nfrom __future__ import annotations\n\nimport asyncio\nimport random\nimport time\nimport uuid\nfrom dataclasses import dataclass\nfrom typing import Any, Dict, Generic, List, Optional, Tuple, TypeVar\n\nimport httpx\n\nfrom ._errors import ApiTimeoutError\n\nT = TypeVar("T")\n\n\n@dataclass\nclass Envelope(Generic[T]):\n """A *_with_headers() result: decoded body + coerced declared headers + raw response."""\n\n data: T\n headers: Dict[str, Any]\n response: httpx.Response\n\n\ndef read_envelope_headers(\n response: httpx.Response, specs: List[Tuple[str, str, str]]\n) -> Dict[str, Any]:\n """Coerce declared response headers per (name, key, type) specs; absent/unparsable omitted."""\n headers: Dict[str, Any] = {}\n for name, key, type_ in specs:\n raw = response.headers.get(name)\n if raw is None:\n continue\n if type_ in ("integer", "number"):\n try:\n headers[key] = int(raw) if type_ == "integer" else float(raw)\n except ValueError:\n pass\n elif type_ == "boolean":\n lower = raw.strip().lower()\n if lower in ("true", "false"):\n headers[key] = lower == "true"\n else:\n headers[key] = raw\n return headers\n\n\n_IDEMPOTENT_METHODS = {"GET", "HEAD", "PUT", "DELETE", "OPTIONS"}\n_TRANSIENT_STATUS = {408, 429, 500, 502, 503, 504}\n\n\ndef _default_retry_on(method: str, headers: Dict[str, str], response: Optional[httpx.Response]) -> bool:\n safe = method.upper() in _IDEMPOTENT_METHODS or "Idempotency-Key" in headers\n if not safe:\n return False\n return response is None or response.status_code in _TRANSIENT_STATUS\n\n\ndef _retry_delay(retry: Dict[str, Any], attempt: int, retry_after: Optional[str]) -> float:\n if retry_after:\n try:\n return float(retry_after)\n except ValueError:\n pass # HTTP-date form: fall through to backoff\n base = float(retry.get("retry_delay", 1.0))\n raw = base if retry.get("retry_strategy") == "fixed" else base * (2 ** (attempt - 1))\n return random.uniform(0, raw) if retry.get("jitter", True) is not False else raw\n\n\ndef send(\n client: httpx.Client,\n config: Dict[str, Any],\n op: Dict[str, Any],\n url: str,\n *,\n method: str,\n headers: Optional[Dict[str, str]] = None,\n params: Optional[Dict[str, Any]] = None,\n json_body: Any = None,\n content: Any = None,\n data: Any = None,\n files: Any = None,\n timeout: Optional[float] = None,\n idempotency_key: Any = None,\n retry: Optional[Dict[str, Any]] = None,\n) -> httpx.Response:\n merged_retry: Dict[str, Any] = {**(config.get("retry") or {}), **(retry or {})}\n effective_timeout = timeout if timeout is not None else config.get("timeout")\n merged_headers: Dict[str, str] = {**(config.get("headers") or {}), **(headers or {})}\n\n # One stable key per LOGICAL call — set before the retry loop so every\n # attempt re-sends the same key; a caller-provided header always wins.\n key = idempotency_key if idempotency_key is not None else config.get("idempotency_key")\n if (\n key not in (None, False)\n and method.upper() in ("POST", "PATCH")\n and "Idempotency-Key" not in merged_headers\n ):\n merged_headers["Idempotency-Key"] = (\n key if isinstance(key, str) else key() if callable(key) else str(uuid.uuid4())\n )\n\n context = {\n "url": url,\n "method": method.upper(),\n "headers": merged_headers,\n "body": json_body,\n "operation": op,\n }\n middleware: List[Any] = config.get("middleware") or []\n for mw in middleware:\n on_request = getattr(mw, "on_request", None) or (mw.get("on_request") if isinstance(mw, dict) else None)\n if on_request:\n on_request(context)\n\n max_attempts = 1 + int(merged_retry.get("retries", 0))\n retry_on = merged_retry.get("retry_on") or (\n lambda ctx: _default_retry_on(context["method"], context["headers"], ctx.get("response"))\n )\n\n attempt = 0\n while True:\n attempt += 1\n try:\n response = client.request(\n context["method"],\n context["url"],\n headers=context["headers"],\n params=params,\n json=context["body"] if content is None and files is None and data is None else None,\n content=content,\n data=data,\n files=files,\n timeout=effective_timeout if effective_timeout is not None else httpx.USE_CLIENT_DEFAULT,\n )\n except httpx.TimeoutException:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n time.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise ApiTimeoutError(op.get("id", "?"), float(effective_timeout or 0), attempt) from None\n except httpx.TransportError:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n time.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise\n\n for mw in reversed(middleware):\n on_response = getattr(mw, "on_response", None) or (mw.get("on_response") if isinstance(mw, dict) else None)\n if on_response:\n replaced = on_response(response, context)\n if replaced is not None:\n response = replaced\n\n if (\n not response.is_success\n and attempt < max_attempts\n and retry_on({"attempt": attempt, "response": response})\n ):\n time.sleep(_retry_delay(merged_retry, attempt, response.headers.get("retry-after")))\n continue\n return response\n\n\nasync def send_async(\n client: httpx.AsyncClient,\n config: Dict[str, Any],\n op: Dict[str, Any],\n url: str,\n *,\n method: str,\n headers: Optional[Dict[str, str]] = None,\n params: Optional[Dict[str, Any]] = None,\n json_body: Any = None,\n content: Any = None,\n data: Any = None,\n files: Any = None,\n timeout: Optional[float] = None,\n idempotency_key: Any = None,\n retry: Optional[Dict[str, Any]] = None,\n) -> httpx.Response:\n """The async mirror of send() — same retry/timeout/idempotency semantics."""\n merged_retry: Dict[str, Any] = {**(config.get("retry") or {}), **(retry or {})}\n effective_timeout = timeout if timeout is not None else config.get("timeout")\n merged_headers: Dict[str, str] = {**(config.get("headers") or {}), **(headers or {})}\n key = idempotency_key if idempotency_key is not None else config.get("idempotency_key")\n if (\n key not in (None, False)\n and method.upper() in ("POST", "PATCH")\n and "Idempotency-Key" not in merged_headers\n ):\n merged_headers["Idempotency-Key"] = (\n key if isinstance(key, str) else key() if callable(key) else str(uuid.uuid4())\n )\n context = {\n "url": url,\n "method": method.upper(),\n "headers": merged_headers,\n "body": json_body,\n "operation": op,\n }\n middleware: List[Any] = config.get("middleware") or []\n for mw in middleware:\n on_request = getattr(mw, "on_request", None) or (mw.get("on_request") if isinstance(mw, dict) else None)\n if on_request:\n on_request(context)\n max_attempts = 1 + int(merged_retry.get("retries", 0))\n retry_on = merged_retry.get("retry_on") or (\n lambda ctx: _default_retry_on(context["method"], context["headers"], ctx.get("response"))\n )\n attempt = 0\n while True:\n attempt += 1\n try:\n response = await client.request(\n context["method"],\n context["url"],\n headers=context["headers"],\n params=params,\n json=context["body"] if content is None and files is None and data is None else None,\n content=content,\n data=data,\n files=files,\n timeout=effective_timeout if effective_timeout is not None else httpx.USE_CLIENT_DEFAULT,\n )\n except httpx.TimeoutException:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n await asyncio.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise ApiTimeoutError(op.get("id", "?"), float(effective_timeout or 0), attempt) from None\n except httpx.TransportError:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n await asyncio.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise\n for mw in reversed(middleware):\n on_response = getattr(mw, "on_response", None) or (mw.get("on_response") if isinstance(mw, dict) else None)\n if on_response:\n replaced = on_response(response, context)\n if replaced is not None:\n response = replaced\n if (\n not response.is_success\n and attempt < max_attempts\n and retry_on({"attempt": attempt, "response": response})\n ):\n await asyncio.sleep(_retry_delay(merged_retry, attempt, response.headers.get("retry-after")))\n continue\n return response\n', '_paginate.py': diff --git a/packages/client-generator/src/emitters/types.ts b/packages/client-generator/src/emitters/types.ts index 4dad85754a..30bce7b741 100644 --- a/packages/client-generator/src/emitters/types.ts +++ b/packages/client-generator/src/emitters/types.ts @@ -1,7 +1,5 @@ -/** - * How `format: date-time`/`date` string fields are typed: - * - `'string'` (default): the wire shape — an ISO string. - * - `'Date'`: a `Date` reference. Opt-in; pair with the `transformers` generator - * so the runtime value matches (the client stays zero-dep — `Date` is standard). - */ -export type DateType = 'string' | 'Date'; +// The 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/generators/__tests__/go.test.ts b/packages/client-generator/src/generators/__tests__/go.test.ts index 44bdf0de56..be3296e9d7 100644 --- a/packages/client-generator/src/generators/__tests__/go.test.ts +++ b/packages/client-generator/src/generators/__tests__/go.test.ts @@ -410,6 +410,106 @@ describe('goGenerator parity features', () => { expectGoCompiles(out); }); + it('maps date/date-time to time.Time and Date under dateType: Date', () => { + const DATE_TIME: SchemaModel = { + kind: 'scalar', + scalar: 'string', + metadata: { format: 'date-time' }, + }; + const DATE: SchemaModel = { kind: 'scalar', scalar: 'string', metadata: { format: 'date' } }; + const dated: ApiModel = { + title: 'Cafe', + version: '1.0.0', + serverUrl: 'https://api.cafe.example', + services: [ + { + name: 'Orders', + operations: [ + { + name: 'listOrders', + specName: 'listOrders', + method: 'get', + path: '/orders', + tags: ['Orders'], + pathParams: [], + queryParams: [{ name: 'since', in: 'query', required: false, schema: DATE_TIME }], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'ref', name: 'Order' }, + }, + ], + errorResponses: [], + }, + ], + }, + ], + schemas: [ + { + name: 'Order', + schema: { + kind: 'object', + properties: [ + { name: 'placedAt', schema: DATE_TIME, required: true }, + { name: 'dueDate', schema: DATE, required: false }, + { name: 'reminders', schema: { kind: 'array', items: DATE_TIME }, required: false }, + ], + }, + }, + ], + securitySchemes: [], + } as unknown as ApiModel; + + const out = goGenerator({ + model: dated, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: { dateType: 'Date' }, + })[0].content; + + expect(out).toContain('PlacedAt time.Time `json:"placedAt"`'); + // A calendar date needs its own type: encoding/json only speaks RFC 3339 for time.Time. + expect(out).toContain('DueDate *Date `json:"dueDate,omitempty"`'); + expect(out).toContain('Reminders []time.Time `json:"reminders,omitempty"`'); + expect(out).toContain('Since *time.Time'); + expect(out).toContain('query.Set("since", (*params.Since).Format(time.RFC3339))'); + expectGoCompiles(out); + + // The default keeps the wire representation. + const asString = goGenerator({ + model: dated, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: {}, + })[0].content; + expect(asString).toContain('PlacedAt string `json:"placedAt"`'); + }); + + it('models referencing dates compile standalone (the models section imports time)', () => { + const out = renderGoModels( + model({ + Order: { + kind: 'object', + properties: [ + { + name: 'placedAt', + schema: { kind: 'scalar', scalar: 'string', metadata: { format: 'date-time' } }, + required: true, + }, + ], + }, + }), + 'Date' + ); + expect(out).toContain('import "time"'); + expect(out).toContain('PlacedAt time.Time'); + expectGoCompiles(out); + }); + it('bakes the serverUrl option, not just the description server', () => { const files = goGenerator({ model: CAFE, diff --git a/packages/client-generator/src/generators/__tests__/php.test.ts b/packages/client-generator/src/generators/__tests__/php.test.ts index e0bbd901aa..bf02654271 100644 --- a/packages/client-generator/src/generators/__tests__/php.test.ts +++ b/packages/client-generator/src/generators/__tests__/php.test.ts @@ -454,6 +454,89 @@ describe('phpGenerator (full client assembly)', () => { expectPhpRuns(out); }); + it('maps date/date-time to DateTimeImmutable under dateType: Date, hydrating both ways', () => { + const DATE_TIME: SchemaModel = { + kind: 'scalar', + scalar: 'string', + metadata: { format: 'date-time' }, + }; + const DATE: SchemaModel = { kind: 'scalar', scalar: 'string', metadata: { format: 'date' } }; + const dated: ApiModel = { + title: 'Cafe', + version: '1.0.0', + serverUrl: 'https://api.cafe.example', + services: [ + { + name: 'Orders', + operations: [ + { + name: 'listOrders', + specName: 'listOrders', + method: 'get', + path: '/orders', + tags: ['Orders'], + pathParams: [], + queryParams: [{ name: 'since', in: 'query', required: false, schema: DATE_TIME }], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'ref', name: 'Order' }, + }, + ], + errorResponses: [], + }, + ], + }, + ], + schemas: [ + { + name: 'Order', + schema: { + kind: 'object', + properties: [ + { name: 'placedAt', schema: DATE_TIME, required: true }, + { name: 'dueDate', schema: DATE, required: false }, + { name: 'reminders', schema: { kind: 'array', items: DATE_TIME }, required: false }, + ], + }, + }, + ], + securitySchemes: [], + } as unknown as ApiModel; + + const out = phpGenerator({ + model: dated, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: { dateType: 'Date' }, + })[0].content; + + expect(out).toContain('public \\DateTimeImmutable $placedAt'); + expect(out).toContain('public ?\\DateTimeImmutable $dueDate = null'); + // Hydration and serialization both convert, including inside arrays. + expect(out).toContain("new \\DateTimeImmutable($data['placedAt'])"); + expect(out).toContain( + "array_map(static fn ($item) => new \\DateTimeImmutable($item), $data['reminders'])" + ); + expect(out).toContain('$this->placedAt->format(\\DateTimeInterface::ATOM)'); + expect(out).toContain("$this->dueDate->format('Y-m-d')"); + expect(out).toContain('?\\DateTimeImmutable $since = null'); + expectPhpRuns(out); + + // The default keeps the wire representation. + const asString = phpGenerator({ + model: dated, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: {}, + })[0].content; + expect(asString).toContain('public string $placedAt'); + }); + it('bakes the serverUrl option, not just the description server', () => { const files = phpGenerator({ model: CAFE, diff --git a/packages/client-generator/src/generators/__tests__/python.test.ts b/packages/client-generator/src/generators/__tests__/python.test.ts index 206ec95eec..83ccd1e33e 100644 --- a/packages/client-generator/src/generators/__tests__/python.test.ts +++ b/packages/client-generator/src/generators/__tests__/python.test.ts @@ -422,6 +422,124 @@ describe('pythonGenerator parity features', () => { expectCompiles(out); }); + it('maps date/date-time to datetime objects under dateType: Date, and round-trips them', () => { + const dated: ApiModel = { + title: 'Cafe', + version: '1.0.0', + serverUrl: 'https://api.cafe.example', + services: [ + { + name: 'Orders', + operations: [ + { + name: 'listOrders', + specName: 'listOrders', + method: 'get', + path: '/orders', + tags: ['Orders'], + pathParams: [], + queryParams: [ + { + name: 'since', + in: 'query', + required: false, + schema: { kind: 'scalar', scalar: 'string', metadata: { format: 'date-time' } }, + }, + ], + headerParams: [], + cookieParams: [], + security: [], + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'array', items: { kind: 'ref', name: 'Order' } }, + }, + ], + errorResponses: [], + }, + ], + }, + ], + schemas: [ + { + name: 'Order', + schema: { + kind: 'object', + properties: [ + { + name: 'placedAt', + schema: { kind: 'scalar', scalar: 'string', metadata: { format: 'date-time' } }, + required: true, + }, + { + name: 'dueDate', + schema: { kind: 'scalar', scalar: 'string', metadata: { format: 'date' } }, + required: false, + }, + { + name: 'reminders', + schema: { + kind: 'array', + items: { kind: 'scalar', scalar: 'string', metadata: { format: 'date-time' } }, + }, + required: false, + }, + ], + }, + }, + ], + securitySchemes: [], + } as unknown as ApiModel; + + const out = pythonGenerator({ + model: dated, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: { dateType: 'Date' }, + })[0].content; + + expect(out).toContain('from datetime import date, datetime'); + expect(out).toContain('placed_at: datetime'); + expect(out).toContain('due_date: Optional[date] = None'); + // Nested positions must convert too, not just top-level fields. + expect(out).toContain('reminders: Optional[List[datetime]] = None'); + expect(out).toContain('since: Optional[datetime] = None'); + // dateType: string (the default) keeps the wire representation. + const asString = pythonGenerator({ + model: dated, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: {}, + })[0].content; + expect(asString).toContain('placed_at: str'); + expect(asString).not.toContain('placed_at: datetime'); + expectCompiles(out); + + // Behavioral: the runtime decodes ISO strings into objects and encodes them back. + if (!hasHttpx) return; + const dir = mkdtempSync(join(tmpdir(), 'py-dates-')); + try { + writeFileSync(join(dir, 'client.py'), out); + const run = spawnSync( + 'python3', + [ + '-c', + 'import client;' + + ' o = client.decode(client.Order, {"placedAt": "2026-08-05T10:00:00+00:00", "dueDate": "2026-08-06", "reminders": ["2026-08-07T12:00:00+00:00"]});' + + ' print(type(o.placed_at).__name__, type(o.due_date).__name__, type(o.reminders[0]).__name__);' + + ' print(client.encode(o))', + ], + { cwd: dir, encoding: 'utf-8' } + ); + expect(run.status, run.stderr).toBe(0); + expect(run.stdout).toContain('datetime date datetime'); + expect(run.stdout).toContain('2026-08-06'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it('bakes the serverUrl option, not just the description server', () => { const files = pythonGenerator({ model: CAFE, diff --git a/packages/client-generator/src/generators/go/AGENTS.md b/packages/client-generator/src/generators/go/AGENTS.md index 50562c4e87..c9445af792 100644 --- a/packages/client-generator/src/generators/go/AGENTS.md +++ b/packages/client-generator/src/generators/go/AGENTS.md @@ -22,7 +22,11 @@ runtime. Go ≥ 1.21, standard library only — zero dependencies. **discriminated unions** are `type X = any` plus a generated `UnmarshalX([]byte)` dispatcher; **allOf** is flattened. - **Errors:** `(T, error)` returns ARE the error mode — `errorMode` does not change the - output. Non-2xx → `*APIError`; timeouts → `*TimeoutError`. + output (the generator declares `errorModes: ['throw']`, so `result` fails fast). + Non-2xx → `*APIError`; timeouts → `*TimeoutError`. +- **Dates:** `dateType: Date` maps `format: date-time` to `time.Time` (encoding/json + handles RFC 3339 natively) and `date` to the runtime's `Date` wrapper, which + marshals as `2006-01-02`. Query values format explicitly, never via `String()`. - **Response headers:** an operation that DECLARES success-response headers gains a `WithHeaders(ctx, …) (T, Headers, error)` variant; `Headers` is a generated struct with pointer fields (nil when absent or unparsable), coerced to diff --git a/packages/client-generator/src/generators/go/index.ts b/packages/client-generator/src/generators/go/index.ts index 6a30908690..50aee26cea 100644 --- a/packages/client-generator/src/generators/go/index.ts +++ b/packages/client-generator/src/generators/go/index.ts @@ -18,6 +18,7 @@ import { RESERVED_WORDS, schemaAtPointer, unwrapNullable, + type DateType, type NeutralPaginationRule, } from '../../authoring/index.js'; import { GO_RUNTIME_SOURCE } from '../../emitters/go-runtime-sources.js'; @@ -41,20 +42,26 @@ function exported(name: string): string { } /** The Go type for a schema; `required=false` optionals become pointers at the field site. */ -export function goType(schema: SchemaModel): string { +export function goType(schema: SchemaModel, dateType: DateType = 'string'): string { if (isNullable(schema)) { - const inner = goType(unwrapNullable(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)}`; + return `[]${goType(schema.items, dateType)}`; case 'record': - return `map[string]${goType(schema.value)}`; + return `map[string]${goType(schema.value, dateType)}`; case 'ref': return exported(schema.name); case 'literal': @@ -92,6 +99,7 @@ function writeStruct( printer: Printer, name: string, properties: PropertyModel[], + dateType: DateType, description?: string ): void { writeDocComment(printer, exported(name), description); @@ -100,7 +108,7 @@ function writeStruct( () => { for (const property of properties) { const field = exported(property.name); - let fieldType = goType(property.schema); + let fieldType = goType(property.schema, dateType); let tag = `\`json:"${property.name}"\``; if (!property.required) { if ( @@ -122,7 +130,7 @@ function writeStruct( } /** Render every named schema: typed-const enums, structs (allOf flattened), union dispatchers. */ -export function renderGoModels(model: ApiModel): string { +export function renderGoModels(model: ApiModel, dateType: DateType = 'string'): string { const printer = new Printer('\t'); printer.line('package client'); printer.blank(); @@ -133,6 +141,20 @@ export function renderGoModels(model: ApiModel): string { 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 Printer('\t'); for (const { name, schema } of model.schemas) { const asEnum = enumValues(schema); @@ -157,7 +179,13 @@ export function renderGoModels(model: ApiModel): string { if (schema.kind === 'object' || schema.kind === 'intersection') { const flat = flattenAllOf(schema, model); if (flat !== undefined) { - writeStruct(printer, name, flat.properties, flat.description ?? schema.description); + writeStruct( + printer, + name, + flat.properties, + dateType, + flat.description ?? schema.description + ); continue; } } @@ -214,7 +242,7 @@ export function renderGoModels(model: ApiModel): string { } // Everything else (plain unions, scalar aliases, records) becomes a type alias. writeDocComment(printer, exported(name), schema.description); - printer.line(`type ${exported(name)} = ${goType(schema)}`); + printer.line(`type ${exported(name)} = ${goType(schema, dateType)}`); printer.blank(); } return printer.toString(); @@ -276,6 +304,11 @@ function goOperationIdents(model: ApiModel): Array<{ op: OperationModel; ident: /** 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})`; @@ -354,17 +387,18 @@ function writeGoMethod( printer: Printer, op: OperationModel, ident: string, + dateType: DateType, model?: ApiModel, envelope = false ): void { const pathArgs = op.pathParams.map((param) => ({ param, go: identifierFor(param.name, { style: 'camel', reserved: GO }), - type: goType(param.schema), + type: goType(param.schema, dateType), })); const hasParams = op.queryParams.length > 0; const success = successSchema(op); - const returnType = success === undefined ? undefined : goType(success); + const returnType = success === undefined ? undefined : goType(success, dateType); const headerPlan = envelope ? envelopeHeaderPlan(op, model!) : []; if (envelope) { printer.line( @@ -382,7 +416,7 @@ function writeGoMethod( const args = [ 'ctx context.Context', ...pathArgs.map(({ go, type }) => `${go} ${type}`), - ...(op.requestBody ? [`body ${goType(op.requestBody.schema)}`] : []), + ...(op.requestBody ? [`body ${goType(op.requestBody.schema, dateType)}`] : []), ...(hasParams ? [`params *${ident}Params`] : []), ]; const sse = sseResponse(op); @@ -426,7 +460,7 @@ function writeGoMethod( `if params.${field} != nil {`, () => { printer.line( - `query.Set(${JSON.stringify(param.name)}, ${goQueryFormat(`*params.${field}`, goType(param.schema))})` + `query.Set(${JSON.stringify(param.name)}, ${goQueryFormat(`*params.${field}`, goType(param.schema, dateType))})` ); }, '}' @@ -554,13 +588,14 @@ function writeGoPaginationWrappers( printer: Printer, op: OperationModel, ident: string, + dateType: DateType, pageType: string, itemType: string ): void { const pathArgs = op.pathParams.map((param) => ({ param, go: identifierFor(param.name, { style: 'camel', reserved: GO }), - type: goType(param.schema), + type: goType(param.schema, dateType), })); const hasParams = op.queryParams.length > 0; const args = [ @@ -582,7 +617,7 @@ function writeGoPaginationWrappers( `if params.${field} != nil {`, () => { printer.line( - `base.Set(${JSON.stringify(param.name)}, ${goQueryFormat(`*params.${field}`, goType(param.schema))})` + `base.Set(${JSON.stringify(param.name)}, ${goQueryFormat(`*params.${field}`, goType(param.schema, dateType))})` ); }, '}' @@ -793,6 +828,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 dateType = emit.dateType ?? 'string'; const paginationRules = new Map(); for (const { op, ident } of goOperationIdents(model)) { const rule = paginationRuleFor(op, emit.pagination as Record | undefined); @@ -833,7 +869,7 @@ export const goGenerator: Generator = ({ model, outputPath, emit }) => { ); printer.blank(); - printer.line(stripHeader(renderGoModels(model))); + printer.line(stripHeader(renderGoModels(model, dateType))); printer.blank(); writeGoServers(printer, model); printer.line('// ─── Embedded runtime (@redocly/client-generator go runtime) ───'); @@ -880,7 +916,7 @@ export const goGenerator: Generator = ({ model, outputPath, emit }) => { `type ${ident}Params struct {`, () => { for (const param of op.queryParams) { - const fieldType = goType(param.schema); + const fieldType = goType(param.schema, dateType); printer.line( `${exported(param.name)} ${fieldType.startsWith('*') ? fieldType : `*${fieldType}`}` ); @@ -919,14 +955,14 @@ export const goGenerator: Generator = ({ model, outputPath, emit }) => { printer.blank(); for (const { op, ident } of goOperationIdents(model)) { - writeGoMethod(printer, op, ident); + writeGoMethod(printer, op, ident, dateType); if (sseResponse(op) === undefined && (op.successResponseHeaders?.length ?? 0) > 0) { - writeGoMethod(printer, op, ident, model, true); + writeGoMethod(printer, op, ident, dateType, model, true); } const rule = paginationRules.get(ident); if (rule === undefined) continue; const success = successSchema(op); - const pageType = success === undefined ? 'any' : goType(success); + 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 = @@ -938,8 +974,9 @@ export const goGenerator: Generator = ({ model, outputPath, emit }) => { printer, op, ident, + dateType, pageType, - element === undefined ? 'any' : goType(element) + element === undefined ? 'any' : goType(element, dateType) ); } @@ -947,14 +984,15 @@ export const goGenerator: Generator = ({ model, outputPath, emit }) => { }; /** One idiomatic Go call per operation — feeds `x-codeSamples` for docs. */ -export function goSample(op: OperationModel, _ctx: SampleContext): CodeSample { +export function goSample(op: OperationModel, ctx: SampleContext): CodeSample { + const dateType = ctx.emit.dateType ?? 'string'; const ident = exported(op.name); const args = [ 'ctx', ...op.pathParams.map( (param) => `"<${identifierFor(param.name, { style: 'camel', reserved: GO })}>"` ), - ...(op.requestBody ? [`${goType(op.requestBody.schema)}{ /* … */ }`] : []), + ...(op.requestBody ? [`${goType(op.requestBody.schema, dateType)}{ /* … */ }`] : []), ...(op.queryParams.length > 0 ? ['nil'] : []), ]; return { diff --git a/packages/client-generator/src/generators/php/AGENTS.md b/packages/client-generator/src/generators/php/AGENTS.md index 15e1170923..47f1ef3e58 100644 --- a/packages/client-generator/src/generators/php/AGENTS.md +++ b/packages/client-generator/src/generators/php/AGENTS.md @@ -30,7 +30,12 @@ extension — zero Composer dependencies. The namespace derives from the API tit **Discriminated unions** are `match`-based `unmarshalX(array $data)` dispatchers; **allOf** is flattened. - **Errors:** exceptions ARE the error mode (`ApiError`/`TimeoutError` extend - `\RuntimeException`); `errorMode` does not change the output. + `\RuntimeException`); `errorMode` does not change the output (the generator declares + `errorModes: ['throw']`, so `result` fails fast). +- **Dates:** `dateType: Date` types `format: date`/`date-time` as + `\DateTimeImmutable`; hydration is `new \DateTimeImmutable(...)` and serialization + formats with `\DateTimeInterface::ATOM` (date-time) or `'Y-m-d'` (date), including + for query parameters. - **Method arguments:** required path params positional, JSON body next, optional query params as nullable NAMED arguments, then `?array $headers`, and `?string $idempotencyKey` on mutating methods. diff --git a/packages/client-generator/src/generators/php/index.ts b/packages/client-generator/src/generators/php/index.ts index b881571fdd..fa92a85a8c 100644 --- a/packages/client-generator/src/generators/php/index.ts +++ b/packages/client-generator/src/generators/php/index.ts @@ -19,6 +19,7 @@ import { schemaAtPointer, unwrapNullable, type NeutralPaginationRule, + type DateType, } from '../../authoring/index.js'; import { PHP_RUNTIME_SOURCE } from '../../emitters/php-runtime-sources.js'; import type { @@ -79,13 +80,22 @@ function classify(name: string, model: ApiModel): 'class' | 'enum' | 'other' { } /** The PHP type declaration for a schema (arrays and unions widen to array/mixed). */ -export function phpType(schema: SchemaModel, model: ApiModel): string { +export function phpType( + schema: SchemaModel, + model: ApiModel, + dateType: DateType = 'string' +): string { if (isNullable(schema)) { - const inner = phpType(unwrapNullable(schema), model); + const inner = phpType(unwrapNullable(schema), model, dateType); return inner === 'mixed' || inner.startsWith('?') ? inner : `?${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': @@ -94,7 +104,7 @@ export function phpType(schema: SchemaModel, model: ApiModel): string { 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); + return target === undefined ? 'mixed' : phpType(target, model, dateType); } case 'enum': // Anonymous (inline) enums keep the wire scalar; only NAMED enums get types. @@ -123,25 +133,40 @@ function isDiscriminatedUnion(name: string, model: ApiModel): boolean { 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'; +} + /** Wire value → typed value expression, or undefined when the raw value is already right. */ -function hydration(schema: SchemaModel, expr: string, model: ApiModel): string | undefined { +function hydration( + schema: SchemaModel, + expr: string, + model: ApiModel, + dateType: DateType = 'string' +): string | undefined { const bare = unwrapNullable(schema); - if (bare.kind === 'omit') return hydration({ kind: 'ref', name: bare.base }, expr, model); + 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); + return target === undefined ? undefined : hydration(target, expr, model, dateType); } if (bare.kind === 'array') { - const item = hydration(bare.items, '$item', model); + 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); + const item = hydration(bare.value, '$item', model, dateType); if (item === undefined) return undefined; return `array_map(static fn ($item) => ${item}, ${expr})`; } @@ -149,9 +174,23 @@ function hydration(schema: SchemaModel, expr: string, model: ApiModel): string | } /** Typed value → wire value expression, or undefined when it serializes as-is. */ -function serialization(schema: SchemaModel, expr: string, model: ApiModel): string | undefined { +function serialization( + schema: SchemaModel, + expr: string, + model: ApiModel, + dateType: DateType = 'string' +): string | undefined { const bare = unwrapNullable(schema); - if (bare.kind === 'omit') return serialization({ kind: 'ref', name: bare.base }, expr, model); + 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()`; @@ -161,11 +200,11 @@ function serialization(schema: SchemaModel, expr: string, model: ApiModel): stri return `is_object(${expr}) ? ${expr}->toArray() : ${expr}`; } const target = deref(bare, model); - return target === undefined ? undefined : serialization(target, expr, 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); + const item = serialization(inner, '$item', model, dateType); if (item === undefined) return undefined; return `array_map(static fn ($item) => ${item}, ${expr})`; } @@ -183,6 +222,7 @@ function writeClass( name: string, properties: PropertyModel[], model: ApiModel, + dateType: DateType, description?: string ): void { // PHP requires defaulted parameters after required ones. @@ -199,7 +239,7 @@ function writeClass( 'public function __construct(', () => { for (const property of ordered) { - const type = phpType(property.schema, model); + const type = phpType(property.schema, model, dateType); if (property.required) { printer.line(`public ${type} ${'$'}${propertyName(property.name)},`); } else { @@ -222,7 +262,7 @@ function writeClass( () => { for (const property of ordered) { const raw = `$data[${phpString(property.name)}]`; - const typed = hydration(property.schema, raw, model); + const typed = hydration(property.schema, raw, model, dateType); const php = propertyName(property.name); if (property.required) { printer.line(`${php}: ${typed ?? raw},`); @@ -247,7 +287,7 @@ function writeClass( printer.line('$data = [];'); for (const property of ordered) { const value = `$this->${propertyName(property.name)}`; - const wire = serialization(property.schema, value, model) ?? value; + const wire = serialization(property.schema, value, model, dateType) ?? value; if (property.required) { printer.line(`$data[${phpString(property.name)}] = ${wire};`); } else { @@ -271,7 +311,7 @@ function writeClass( } /** Render every named schema: classes (allOf flattened), native enums, union dispatchers. */ -export function renderPhpModels(model: ApiModel): string { +export function renderPhpModels(model: ApiModel, dateType: DateType = 'string'): string { const printer = new Printer(' '); for (const { name, schema } of model.schemas) { const asEnum = enumValues(schema); @@ -296,7 +336,14 @@ export function renderPhpModels(model: ApiModel): string { if (schema.kind === 'object' || schema.kind === 'intersection') { const flat = flattenAllOf(schema, model); if (flat !== undefined) { - writeClass(printer, name, flat.properties, model, flat.description ?? schema.description); + writeClass( + printer, + name, + flat.properties, + model, + dateType, + flat.description ?? schema.description + ); continue; } } @@ -404,25 +451,37 @@ function phpPaginationLiteral(rule: NeutralPaginationRule): string { type MethodArgs = { pathArgs: Array<{ php: string; wire: string; type: string }>; - queryArgs: 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[]; }; -function methodArgs(op: OperationModel, model: ApiModel, includeBody: boolean): MethodArgs { +function methodArgs( + op: OperationModel, + model: ApiModel, + includeBody: boolean, + dateType: DateType +): MethodArgs { 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), + type: phpType(param.schema, model, dateType), })); + const queryArgs = op.queryParams.map((param) => { + const php = propertyName(param.name); + return { + php, + wire: param.name, + type: phpType(param.schema, model, dateType), + value: serialization(param.schema, `${'$'}${php}`, model, dateType) ?? `${'$'}${php}`, + }; + }); const signature = [ ...pathArgs.map(({ php, type }) => `${type} ${'$'}${php}`), ...(includeBody && op.requestBody - ? [`${isMultipart(op) ? 'array' : phpType(op.requestBody.schema, model)} ${'$'}body`] + ? [ + `${isMultipart(op) ? 'array' : phpType(op.requestBody.schema, model, dateType)} ${'$'}body`, + ] : []), ...queryArgs.map(({ php, type }) => { const nullable = type === 'mixed' || type.startsWith('?') ? type : `?${type}`; @@ -442,11 +501,11 @@ function writeRequestSetup(printer: Printer, op: OperationModel, args: MethodArg printer.line( "[$authHeaders, $query, $cookies] = resolveAuth($op['security'] ?? [], $this->config->auth);" ); - for (const { php, wire } of args.queryArgs) { + for (const { php, wire, value } of args.queryArgs) { printer.block( `if (${'$'}${php} !== null) {`, () => { - printer.line(`$query[${phpString(wire)}] = ${'$'}${php};`); + printer.line(`$query[${phpString(wire)}] = ${value};`); }, '}' ); @@ -484,9 +543,10 @@ function writePhpMethod( printer: Printer, op: OperationModel, model: ApiModel, + dateType: DateType, envelope = false ): void { - const args = methodArgs(op, model, true); + const args = methodArgs(op, model, true, dateType); const sse = sseResponse(op); const success = successSchema(op); // Non-JSON success bodies (PDFs, images, octet streams) return the raw body string. @@ -499,7 +559,7 @@ function writePhpMethod( : sse !== undefined ? '\\Generator' : success !== undefined - ? phpType(success, model) + ? phpType(success, model, dateType) : rawBody ? 'string' : 'void'; @@ -554,7 +614,7 @@ function writePhpMethod( printer.line('[$contentType, $encoded] = toMultipart($body);'); request.push(`'body' => $encoded`, `'contentType' => $contentType`); } else if (op.requestBody) { - const wire = serialization(op.requestBody.schema, '$body', model) ?? '$body'; + const wire = serialization(op.requestBody.schema, '$body', model, dateType) ?? '$body'; printer.line(`$payload = json_encode(${wire});`); request.push( `'body' => $payload`, @@ -604,21 +664,22 @@ function writePhpPaginationWrappers( printer: Printer, op: OperationModel, model: ApiModel, + dateType: DateType, pageHydration: string | undefined, itemHydration: string | undefined, itemsPointer: string | undefined ): void { - const args = methodArgs(op, model, false); + const args = methodArgs(op, model, false, dateType); const name = methodName(op); const writeCall = () => { printer.line(`$op = OPERATIONS[${phpString(op.specName ?? op.name)}];`); printer.line('$base = [];'); - for (const { php, wire } of args.queryArgs) { + for (const { php, wire, value } of args.queryArgs) { printer.block( `if (${'$'}${php} !== null) {`, () => { - printer.line(`$base[${phpString(wire)}] = ${'$'}${php};`); + printer.line(`$base[${phpString(wire)}] = ${value};`); }, '}' ); @@ -789,6 +850,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 dateType = emit.dateType ?? 'string'; const namespace = identifierFor(model.title, { style: 'pascal', reserved: PHP }); printer.line(' { printer.blank(); printer.line(`namespace ${namespace};`); printer.blank(); - printer.line(renderPhpModels(model)); + printer.line(renderPhpModels(model, dateType)); writeServers(printer, model); printer.line('// ─── Embedded runtime (@redocly/client-generator php runtime) ───'); printer.line(stripPhpHeader(PHP_RUNTIME_SOURCE)); @@ -862,15 +924,15 @@ export const phpGenerator: Generator = ({ model, outputPath, emit }) => { printer.blank(); for (const op of operations) { - writePhpMethod(printer, op, model); + writePhpMethod(printer, op, model, dateType); if (sseResponse(op) === undefined && (op.successResponseHeaders?.length ?? 0) > 0) { - writePhpMethod(printer, op, model, true); + writePhpMethod(printer, op, model, dateType, true); } const rule = paginationRules.get(op.name); if (rule === undefined) continue; const success = successSchema(op); const pageHydration = - success === undefined ? undefined : hydration(success, '$page', model); + 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 = @@ -879,8 +941,16 @@ export const phpGenerator: Generator = ({ model, outputPath, emit }) => { : undefined; const element = itemsArray?.kind === 'array' ? itemsArray.items : undefined; const itemHydration = - element === undefined ? undefined : hydration(element, '$item', model); - writePhpPaginationWrappers(printer, op, model, pageHydration, itemHydration, rule.items); + element === undefined ? undefined : hydration(element, '$item', model, dateType); + writePhpPaginationWrappers( + printer, + op, + model, + dateType, + pageHydration, + itemHydration, + rule.items + ); } }, '}' diff --git a/packages/client-generator/src/generators/python/AGENTS.md b/packages/client-generator/src/generators/python/AGENTS.md index ce75d4d02f..7765d29970 100644 --- a/packages/client-generator/src/generators/python/AGENTS.md +++ b/packages/client-generator/src/generators/python/AGENTS.md @@ -26,6 +26,9 @@ One self-contained `.py`: typed dataclass models, a sync `Client` and an a hydrates wins — see `_decode.py`). **allOf** is flattened via `flattenAllOf`. - **Errors:** `errorMode` maps to raising `ApiError` (default) or returning a `Result` dataclass — the only generator with both modes outside TypeScript. +- **Dates:** `dateType: Date` annotates `format: date-time` as `datetime` and `date` as + `date`; `_decode.py` parses ISO strings into them and `encode()` writes `isoformat()` + back. The default (`string`) keeps the wire shape. - **Response headers:** an operation that DECLARES success-response headers gains a `_with_headers()` variant (sync and async) returning `Envelope[T]` — `data`, `headers` (coerced to int/bool/str with snake_case keys; absent/unparsable values diff --git a/packages/client-generator/src/generators/python/index.ts b/packages/client-generator/src/generators/python/index.ts index 4f74b1cf28..3cb0c2e11c 100644 --- a/packages/client-generator/src/generators/python/index.ts +++ b/packages/client-generator/src/generators/python/index.ts @@ -16,6 +16,7 @@ import { isNullable, RESERVED_WORDS, unwrapNullable, + type DateType, } from '../../authoring/index.js'; import { PYTHON_RUNTIME_SOURCES } from '../../emitters/python-runtime-sources.js'; import type { @@ -41,17 +42,23 @@ function fieldName(name: string): { python: string; renamed: boolean } { } /** The Python type annotation for a schema (anonymous complex shapes collapse to Any-ish). */ -export function pythonType(schema: SchemaModel): string { +export function pythonType(schema: SchemaModel, dateType: DateType = 'string'): string { if (isNullable(schema)) { - return `Optional[${pythonType(unwrapNullable(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)}]`; + return `List[${pythonType(schema.items, dateType)}]`; case 'record': - return `Dict[str, ${pythonType(schema.value)}]`; + return `Dict[str, ${pythonType(schema.value, dateType)}]`; case 'ref': return className(schema.name); case 'literal': @@ -60,7 +67,7 @@ export function pythonType(schema: SchemaModel): string { // 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(pythonType).join(', ')}]`; + return `Union[${schema.members.map((member) => pythonType(member, dateType)).join(', ')}]`; case 'null': return 'None'; case 'omit': @@ -90,6 +97,7 @@ function writeDataclass( printer: Printer, name: string, properties: PropertyModel[], + dateType: DateType, description?: string ): void { printer.line('@dataclass'); @@ -105,7 +113,7 @@ function writeDataclass( for (const property of ordered) { const { python, renamed } = fieldName(property.name); if (renamed) fieldMap.push([python, property.name]); - const baseType = pythonType(property.schema); + const baseType = pythonType(property.schema, dateType); if (property.required) { printer.line(`${python}: ${baseType}`); } else { @@ -125,7 +133,7 @@ function writeDataclass( } /** Render every named schema: Enum classes, dataclasses (allOf flattened), union aliases. */ -export function renderPythonModels(model: ApiModel): string { +export function renderPythonModels(model: ApiModel, dateType: DateType = 'string'): string { const printer = new Printer(' '); printer.line('from __future__ import annotations'); printer.blank(); @@ -134,6 +142,8 @@ export function renderPythonModels(model: ApiModel): string { printer.line( 'from typing import Any, AsyncIterator, ClassVar, Dict, Iterator, List, Literal, Optional, Tuple, Union' ); + // 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(); @@ -155,7 +165,13 @@ export function renderPythonModels(model: ApiModel): string { if (schema.kind === 'object' || schema.kind === 'intersection') { const flat = flattenAllOf(schema, model); if (flat !== undefined) { - writeDataclass(printer, name, flat.properties, flat.description ?? schema.description); + writeDataclass( + printer, + name, + flat.properties, + dateType, + flat.description ?? schema.description + ); continue; } } @@ -169,7 +185,7 @@ export function renderPythonModels(model: ApiModel): string { .join(', '); printer.line(`# Discriminated by "${cases.property}": ${table}`); } - printer.line(`${className(name)} = ${pythonType(schema)}`); + printer.line(`${className(name)} = ${pythonType(schema, dateType)}`); printer.blank(); }); } @@ -354,6 +370,7 @@ function writeMethod( ident: string, errorMode: 'throw' | 'result', isAsync: boolean, + dateType: DateType, model?: ApiModel, envelope = false ): void { @@ -365,11 +382,13 @@ function writeMethod( param, python: identifierFor(param.name, { style: 'snake', reserved: PY }), })); - const positional = pathArgs.map(({ param, python }) => `${python}: ${pythonType(param.schema)}`); - const bodyArg = op.requestBody ? [`body: ${pythonType(op.requestBody.schema)}`] : []; + 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); + const annotation = pythonType(param.schema, dateType); const optional = annotation.startsWith('Optional[') ? annotation : `Optional[${annotation}]`; return `${python}: ${optional} = None`; }), @@ -381,14 +400,14 @@ function writeMethod( const success = successSchema(op); const sse = sseResponse(op); const returns = envelope - ? `Envelope[${success === undefined ? 'None' : pythonType(success)}]` + ? `Envelope[${success === undefined ? 'None' : pythonType(success, dateType)}]` : sse !== undefined ? `${isAsync ? 'AsyncIterator' : 'Iterator'}[ServerSentEvent]` : errorMode === 'result' ? 'Result' : success === undefined ? 'None' - : pythonType(success); + : 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'; @@ -438,7 +457,9 @@ function writeMethod( 'timeout=timeout, retry=retry, idempotency_key=idempotency_key)' ); const decoded = - success === undefined ? 'None' : `decode(${pythonType(success)}, _safe_json(response))`; + success === undefined + ? 'None' + : `decode(${pythonType(success, dateType)}, _safe_json(response))`; if (envelope) { printer.block('if not response.is_success:', () => { printer.line( @@ -471,10 +492,11 @@ function writePaginationWrappers( op: OperationModel, ident: string, isAsync: boolean, - itemType: string + itemType: string, + dateType: DateType ): void { const success = successSchema(op); - const pageType = success === undefined ? 'Any' : pythonType(success); + const pageType = success === undefined ? 'Any' : pythonType(success, dateType); const queryArgs = op.queryParams.map((param) => ({ param, python: identifierFor(param.name, { style: 'snake', reserved: PY }), @@ -567,7 +589,8 @@ function writeClientClass( errorMode: 'throw' | 'result', isAsync: boolean, paginationSpecs: Map | undefined>, - serverUrl: string + serverUrl: string, + dateType: DateType ): void { const name = isAsync ? 'AsyncClient' : 'Client'; const httpType = isAsync ? 'httpx.AsyncClient' : 'httpx.Client'; @@ -599,9 +622,9 @@ function writeClientClass( ); printer.blank(); for (const { op, ident } of operationIdents(model)) { - writeMethod(printer, op, ident, errorMode, isAsync); + writeMethod(printer, op, ident, errorMode, isAsync, dateType); if (sseResponse(op) === undefined && (op.successResponseHeaders?.length ?? 0) > 0) { - writeMethod(printer, op, ident, errorMode, isAsync, model, true); + writeMethod(printer, op, ident, errorMode, isAsync, dateType, model, true); } const spec = paginationSpecs.get(ident); if (spec !== undefined) { @@ -618,7 +641,8 @@ function writeClientClass( op, ident, isAsync, - element === undefined ? 'Any' : pythonType(element) + element === undefined ? 'Any' : pythonType(element, dateType), + dateType ); } } @@ -629,6 +653,7 @@ function writeClientClass( /** The whole generated file: header, models, embedded runtime, descriptors, clients. */ export const pythonGenerator: Generator = ({ model, outputPath, emit }) => { const errorMode = emit.errorMode ?? 'throw'; + const dateType = emit.dateType ?? 'string'; const printer = new Printer(' '); printer.line( `# Generated by @redocly/client-generator (python) from "${model.title}" ${model.version}.` @@ -638,7 +663,7 @@ export const pythonGenerator: Generator = ({ model, outputPath, emit }) => { printer.blank(); // Models (with the shared imports header). - printer.line(renderPythonModels(model).trimEnd()); + printer.line(renderPythonModels(model, dateType).trimEnd()); printer.blank(); printer.blank(); writePythonServers(printer, model); @@ -702,8 +727,8 @@ export const pythonGenerator: Generator = ({ model, outputPath, emit }) => { // The `serverUrl` option overrides the description's server, like the TS sdk. const serverUrl = emit.serverUrl ?? model.serverUrl ?? ''; - writeClientClass(printer, model, errorMode, false, paginationSpecs, serverUrl); - writeClientClass(printer, model, errorMode, true, paginationSpecs, serverUrl); + writeClientClass(printer, model, errorMode, false, paginationSpecs, serverUrl, dateType); + writeClientClass(printer, model, errorMode, true, paginationSpecs, serverUrl, dateType); return [{ path: outputPath.replace(/\.[^.\\/]+$/, '.py'), content: printer.toString() }]; }; diff --git a/tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md b/tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md index 129c26d4e5..d5cc1dd4cc 100644 --- a/tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md +++ b/tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md @@ -29,7 +29,12 @@ extension — zero Composer dependencies. The namespace derives from the API tit **Discriminated unions** are `match`-based `unmarshalX(array $data)` dispatchers; **allOf** is flattened. - **Errors:** exceptions ARE the error mode (`ApiError`/`TimeoutError` extend - `\RuntimeException`); `errorMode` does not change the output. + `\RuntimeException`); `errorMode` does not change the output (the generator declares + `errorModes: ['throw']`, so `result` fails fast). +- **Dates:** `dateType: Date` types `format: date`/`date-time` as + `\DateTimeImmutable`; hydration is `new \DateTimeImmutable(...)` and serialization + formats with `\DateTimeInterface::ATOM` (date-time) or `'Y-m-d'` (date), including + for query parameters. - **Method arguments:** required path params positional, JSON body next, optional query params as nullable NAMED arguments, then `?array $headers`, and `?string $idempotencyKey` on mutating methods. From e7d282fa17391d293c7cd0354ef64a87c4129648 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 5 Aug 2026 14:39:15 +0300 Subject: [PATCH 084/211] test: pin the embedded language runtimes to their source files --- .../__tests__/runtime-embed-freshness.test.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 packages/client-generator/src/generators/__tests__/runtime-embed-freshness.test.ts diff --git a/packages/client-generator/src/generators/__tests__/runtime-embed-freshness.test.ts b/packages/client-generator/src/generators/__tests__/runtime-embed-freshness.test.ts new file mode 100644 index 0000000000..e8cee9197d --- /dev/null +++ b/packages/client-generator/src/generators/__tests__/runtime-embed-freshness.test.ts @@ -0,0 +1,39 @@ +// The hand-written language runtimes are embedded as strings at prepare time +// (scripts/generate-runtime-sources.mjs). Editing a runtime file WITHOUT re-running +// prepare ships a stale runtime: the generator's own unit bars still pass (they assert +// on generated declarations, not runtime behavior), so the mismatch only surfaces at +// the compile bar — or in a user's client. This pins snapshot == source. + +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { GO_RUNTIME_SOURCE } from '../../emitters/go-runtime-sources.js'; +import { PHP_RUNTIME_SOURCE } from '../../emitters/php-runtime-sources.js'; +import { PYTHON_RUNTIME_SOURCES } from '../../emitters/python-runtime-sources.js'; + +const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../..'); +const STALE = 'stale embed — run `npm run prepare -w @redocly/client-generator`'; + +describe('embedded runtimes match their source files', () => { + it('go', () => { + const source = readFileSync(join(pkgRoot, 'go-runtime/runtime.go'), 'utf-8'); + expect(GO_RUNTIME_SOURCE, STALE).toBe(source); + }); + + it('php', () => { + const source = readFileSync(join(pkgRoot, '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, 'python-runtime'); + const onDisk = readdirSync(dir).filter((name) => name.endsWith('.py')); + expect(Object.keys(PYTHON_RUNTIME_SOURCES).sort(), STALE).toEqual(onDisk.sort()); + for (const name of onDisk) { + expect(PYTHON_RUNTIME_SOURCES[name], `${name}: ${STALE}`).toBe( + readFileSync(join(dir, name), 'utf-8') + ); + } + }); +}); From e75de09f8db2f335ee14f9abd2011d98d14425a7 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 5 Aug 2026 15:08:47 +0300 Subject: [PATCH 085/211] refactor: give every generator a folder and an AGENTS.md design skill --- packages/client-generator/ARCHITECTURE.md | 23 ++++---- packages/client-generator/CONTEXT.md | 5 +- .../src/__tests__/pipeline-ts-free.test.ts | 4 +- .../src/generators/__tests__/cli.test.ts | 2 +- .../__tests__/generator-skills.test.ts | 34 ++++++++--- .../src/generators/__tests__/index.test.ts | 4 +- .../src/generators/__tests__/mock.test.ts | 2 +- .../__tests__/runtime-embed-freshness.test.ts | 5 +- .../src/generators/__tests__/sdk.test.ts | 2 +- .../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/AGENTS.md | 49 ++++++++++++++++ .../src/generators/{cli.ts => cli/index.ts} | 8 +-- .../client-generator/src/generators/index.ts | 14 ++--- .../client-generator/src/generators/meta.ts | 18 +++--- .../src/generators/mock/AGENTS.md | 47 ++++++++++++++++ .../src/generators/{mock.ts => mock/index.ts} | 8 +-- .../src/generators/sdk/AGENTS.md | 56 +++++++++++++++++++ .../src/generators/{sdk.ts => sdk/index.ts} | 10 ++-- .../src/generators/swr/AGENTS.md | 46 +++++++++++++++ .../src/generators/{swr.ts => swr/index.ts} | 8 +-- .../src/generators/tanstack-query/AGENTS.md | 48 ++++++++++++++++ .../index.ts} | 8 +-- .../src/generators/transformers/AGENTS.md | 45 +++++++++++++++ .../index.ts} | 8 +-- .../src/generators/zod/AGENTS.md | 45 +++++++++++++++ .../src/generators/{zod.ts => zod/index.ts} | 8 +-- 29 files changed, 437 insertions(+), 78 deletions(-) create mode 100644 packages/client-generator/src/generators/cli/AGENTS.md rename packages/client-generator/src/generators/{cli.ts => cli/index.ts} (85%) create mode 100644 packages/client-generator/src/generators/mock/AGENTS.md rename packages/client-generator/src/generators/{mock.ts => mock/index.ts} (79%) create mode 100644 packages/client-generator/src/generators/sdk/AGENTS.md rename packages/client-generator/src/generators/{sdk.ts => sdk/index.ts} (86%) create mode 100644 packages/client-generator/src/generators/swr/AGENTS.md rename packages/client-generator/src/generators/{swr.ts => swr/index.ts} (84%) create mode 100644 packages/client-generator/src/generators/tanstack-query/AGENTS.md rename packages/client-generator/src/generators/{tanstack-query.ts => tanstack-query/index.ts} (87%) create mode 100644 packages/client-generator/src/generators/transformers/AGENTS.md rename packages/client-generator/src/generators/{transformers.ts => transformers/index.ts} (85%) create mode 100644 packages/client-generator/src/generators/zod/AGENTS.md rename packages/client-generator/src/generators/{zod.ts => zod/index.ts} (82%) diff --git a/packages/client-generator/ARCHITECTURE.md b/packages/client-generator/ARCHITECTURE.md index 413461dc3a..4702a382af 100644 --- a/packages/client-generator/ARCHITECTURE.md +++ b/packages/client-generator/ARCHITECTURE.md @@ -47,16 +47,15 @@ flowchart LR ## Module map -| Area | Files | Owns | Depth | -| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------- | -| Entry | `index.ts`, `types.ts`, `config.ts`, `config-file.ts`, `plugin.ts` | `generateClient` orchestration; public option/result types; config loading; the experimental `@redocly/client-generator` entry (`defineGenerator` + IR types + codegen toolkit) | thin orchestrator | -| Load | `loader.ts` | bundle + `$ref` resolution, preserving internal refs | deep (hides `openapi-core`) | -| IR | `intermediate-representation/build.ts`, `intermediate-representation/model.ts`, `intermediate-representation/refs.ts`, `intermediate-representation/normalize-swagger2.ts`, `intermediate-representation/sanitize-identifiers.ts` | OpenAPI → IR; the IR type model; ref collection; Swagger 2.0 → 3.x normalization; coerce document-derived names to safe unique identifiers (security boundary) | deep (`buildApiModel` + `normalizeSwagger2` each one interface over a whole walk) | -| Writers | `writers/index.ts`, `single-file-writer.ts`, `split-writer.ts`, `util.ts`, `types.ts` | file layout per output mode (`single`, `split`) over the shared wiring emitter | thin adapters at the `getWriter` seam | -| Generators | `generators/index.ts` (registry + `validateGenerators`), `resolve.ts` (built-in / inline / specifier resolution), `types.ts`, `sdk.ts`, `zod.ts`, `tanstack-query.ts`, `swr.ts`, `transformers.ts`, `mock.ts` | the generator registry seam: each descriptor declares its requires/errorModes/dateTypes/runtimes and produces `GeneratedFile[]` by calling an emitter; `resolve.ts` turns a selection (built-in names, inline `customGenerators`, or plugin import specifiers) into a name→descriptor registry | thin adapters at the `getGenerator` seam ([ADR-0004](./docs/adr/0004-registry-seams.md), [ADR-0012](./docs/adr/0012-plugin-api.md)) | -| Runtime | `runtime/types.ts`, `errors.ts`, `url.ts`, `parse.ts`, `retry.ts`, `multipart.ts`, `auth.ts`, `setup.ts`, `send.ts`, `sse.ts`, `create-client.ts`, `index.ts` (the package barrel) | the client engine as real, unit-testable TypeScript modules: `createClient` builds a typed instance client over operation descriptors, dispatching optional behaviors (multipart, auth, SSE) through a capability seam; the barrel wires the full capability set for package-mode consumers | deep (`createClient` is one interface over the whole engine) | -| Emitters | sdk wiring: `emitters/package-client.ts` (the shared wiring emitter), `descriptor.ts` (OPERATIONS + `Ops`), `inline-runtime.ts` (the inline assembler) + generated `runtime-sources.ts`, `client.ts` (options + banners), `types.ts`, `type-guards.ts`, `auth.ts` (setter names), `operations.ts` (+ `operation-aliases.ts`, `operation-types.ts`), `sse.ts`, `setup-bake.ts`; satellite: `zod.ts`, `transformers.ts`, `tanstack-query.ts`, `swr.ts` (+ shared `wrapper-support.ts`), `mock.ts`/`faker.ts`/`sample.ts`; foundation `ts.ts`; shared `operation-signature.ts`; private `support.ts`, `jsdoc.ts`, `identifier.ts` | IR → TypeScript AST (`ts.factory` nodes, printed via `ts.ts`); `descriptor.ts` emits the pure-data operation descriptors and the `Ops` type; `sse.ts` is the SSE detection seam; `operation-signature.ts` is the single source of an operation’s calling convention; `wrapper-support.ts` is the shared eligibility/param model for `swr` + `tanstack-query` | each emitter is deep (one entry point builds nodes over hidden bulk); `package-client.ts` assembles the per-file content and prints once | -| Errors | `errors.ts` | `NotSupportedError` | trivial | +| Area | Files | Owns | Depth | +| ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| Entry | `index.ts`, `types.ts`, `config.ts`, `config-file.ts`, `plugin.ts` | `generateClient` orchestration; public option/result types; config loading; the experimental `@redocly/client-generator` entry (`defineGenerator` + IR types + codegen toolkit) | thin orchestrator | +| Load | `loader.ts` | bundle + `$ref` resolution, preserving internal refs | deep (hides `openapi-core`) | +| IR | `intermediate-representation/build.ts`, `intermediate-representation/model.ts`, `intermediate-representation/refs.ts`, `intermediate-representation/normalize-swagger2.ts`, `intermediate-representation/sanitize-identifiers.ts` | OpenAPI → IR; the IR type model; ref collection; Swagger 2.0 → 3.x normalization; coerce document-derived names to safe unique identifiers (security boundary) | deep (`buildApiModel` + `normalizeSwagger2` each one interface over a whole walk) | +| Generators | `generators/index.ts` (registry + `validateGenerators`), `meta.ts` (lazy-load metadata + selection validation), `resolve.ts` (built-in / inline / specifier resolution), `contract.ts` (`GENERATOR_CONTRACT`), `types.ts`, and ONE FOLDER PER GENERATOR — `sdk/`, `zod/`, `tanstack-query/`, `swr/`, `transformers/`, `mock/`, `cli/`, `python/`, `go/`, `php/` — each holding `index.ts` plus its own `AGENTS.md` design skill | the generator registry seam: each descriptor declares its requires/errorModes/dateTypes/runtimes (plus `notApplicable` options it can't honor) and produces `GeneratedFile[]`; `resolve.ts` turns a selection into a name→descriptor registry. The three language generators are self-contained single files (hence ejectable); the TypeScript ones are thin entries over the shared emitters | thin adapters at the `getGenerator` seam ([ADR-0004](./docs/adr/0004-registry-seams.md), [ADR-0012](./docs/adr/0012-plugin-api.md)) | +| Runtime | `runtime/types.ts`, `errors.ts`, `url.ts`, `parse.ts`, `retry.ts`, `multipart.ts`, `auth.ts`, `setup.ts`, `send.ts`, `sse.ts`, `create-client.ts`, `index.ts` (the package barrel) | the client engine as real, unit-testable TypeScript modules: `createClient` builds a typed instance client over operation descriptors, dispatching optional behaviors (multipart, auth, SSE) through a capability seam; the barrel wires the full capability set for package-mode consumers | deep (`createClient` is one interface over the whole engine) | +| Emitters | sdk: `emitters/client-assembly.ts` (assembly + output modes), `render-client.ts` (Ops, `*` aliases, flat sugar), `descriptor.ts`, `ts-type.ts`/`ts-literal.ts`, `type-guards.ts`, `sse.ts`, `pagination.ts`, `response-headers.ts`, `inline-runtime.ts` + generated `runtime-sources.ts`, `setup-bake.ts`; satellite: `zod.ts`, `transformers.ts`, `tanstack-query.ts`, `swr.ts` (+ shared `wrapper-support.ts`), `mock.ts`/`mock-value.ts`/`faker.ts`/`sample.ts`, `cli.ts`; shared `operation-signature.ts`; private `support.ts`, `jsdoc.ts`, `identifier.ts`; `ts.ts` (the last `typescript` dependency — `--setup` baking only) | IR → TypeScript SOURCE TEXT (templates over `Printer`-style string building, not an AST — see the ADR on the AST removal); `descriptor.ts` emits the pure-data descriptors and the `Ops` type; `sse.ts` is the SSE detection seam; `operation-signature.ts` is the single source of an operation's calling convention; `wrapper-support.ts` is the shared eligibility/param model for `swr` + `tanstack-query` | each emitter is deep (one entry point over hidden bulk); `client-assembly.ts` assembles per-file content | +| Errors | `errors.ts` | `NotSupportedError` | trivial | The IR (`intermediate-representation/model.ts`) is a **pure type model** — no runtime code. It is the contract between the builder and the emitters ([ADR-0003](./docs/adr/0003-spec-agnostic-ir.md)). @@ -122,11 +121,11 @@ Compile (`npm run compile`) before running tests — they run against built outp - **A new output mode** — add the literal to `OutputMode` (`writers/types.ts`), write a `Writer` over the shared wiring emitter, and register it in the `WRITERS` map (`writers/index.ts`). Wire the CLI choice in the `generate-client` command. -- **A new schema kind** — add the variant to `SchemaModel` (`intermediate-representation/model.ts`), produce it in `intermediate-representation/build.ts`, and build its `ts.TypeNode` in `schemaToTypeNode` (`emitters/types.ts`). +- **A new schema kind** — add the variant to `SchemaModel` (`intermediate-representation/model.ts`), produce it in `intermediate-representation/build.ts`, render it in `tsType` (`emitters/ts-type.ts`), and cover it in each language generator's type mapper (`generators//index.ts`). - **A new runtime capability** — add a module under `src/runtime/`, thread it through the `Capabilities` seam (`runtime/create-client.ts`), wire it in the barrel (`runtime/index.ts`), list it in `scripts/generate-runtime-sources.mjs`, and teach `emitters/inline-runtime.ts` when to embed it (a new `InlineRuntimeNeeds` flag). Run `npm run compile` to regenerate the `runtime-sources.ts` snapshot. - **A new wrapper generator** (a framework adapter that forwards to the sdk functions) — reuse `emitters/wrapper-support.ts` for operation eligibility (SSE / `Variables`-collision skips) and the `vars`/`init` parameter shape, and derive the forwarding call's argument order and `Variables` naming from `operationSignature` (`emitters/operation-signature.ts`), the same source the sdk's parameter list uses, so the wrappers cannot drift. - Declare its compatibility contract (`requires`/`errorModes`/`dateTypes`/`runtimes`) in the generator registry (`generators/index.ts`). + Declare its compatibility contract (`requires`/`errorModes`/`dateTypes`/`runtimes`, plus `notApplicable` for options it cannot honor) in `generators/meta.ts`, and give it a folder with an `AGENTS.md` design skill (a guard test enforces this). See [ADR-0011](./docs/adr/0011-wrapper-generators.md). - **A new mock data source** — the `mock` generator's data comes from `emitters/sample.ts` (baked literals) or `emitters/faker.ts` (faker calls), selected by `--mock-data`; both walk the IR with the same cycle semantics. See [ADR-0010](./docs/adr/0010-mock-data-baked-vs-faker.md). diff --git a/packages/client-generator/CONTEXT.md b/packages/client-generator/CONTEXT.md index 908687c0ea..3925fc3e47 100644 --- a/packages/client-generator/CONTEXT.md +++ b/packages/client-generator/CONTEXT.md @@ -55,8 +55,9 @@ A Writer is an implementation detail of the `sdk` **Generator**. _Avoid_: formatter, builder. **Generator**: -A deep module that turns the IR into a set of files for one concern, selected by name through `getGenerator(name)` (mirrors the `getWriter(outputMode)` seam). -Lives in `generators/`. +A deep module that turns the IR into a set of files for one concern, selected by name through the registry seam. +Each one lives in its OWN FOLDER under `generators/` — `index.ts` plus an `AGENTS.md` design skill that the code must match (change the skill first). +The `python`, `go`, and `php` generators are self-contained single files, which is what makes them ejectable; the TypeScript-emitting ones are thin entries over the shared emitters. The `sdk` generator is the typed client (it delegates to the output-mode **Writer**). The `zod` generator emits a standalone `.zod.ts` **schema module** (one `export const Schema` per IR named schema) beside the client. The `tanstack-query` generator emits a TanStack Query v5 (React) module (`.tanstack.ts`) wrapping the sdk — per query op a `QueryKey`/`Options` (`queryOptions`) factory + query key, per mutation a `Mutation` (`mutationKey`/`mutationFn`) factory (requires the `sdk` generator; the consumer installs `@tanstack/react-query`). 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 27bb4b546d..d151d2b290 100644 --- a/packages/client-generator/src/__tests__/pipeline-ts-free.test.ts +++ b/packages/client-generator/src/__tests__/pipeline-ts-free.test.ts @@ -59,9 +59,9 @@ describe('pipeline (lib/pipeline.js)', () => { }); }); -describe('the sdk generator itself (lib/generators/sdk.js)', () => { +describe('the sdk generator itself (lib/generators/sdk/index.js)', () => { it('loads no typescript — the whole emit path is text templates (setup baking stays lazy)', () => { - const { externals } = staticGraph(join(libDir, 'generators/sdk.js')); + const { externals } = staticGraph(join(libDir, 'generators/sdk/index.js')); expect(externals.has('typescript')).toBe(false); }); }); diff --git a/packages/client-generator/src/generators/__tests__/cli.test.ts b/packages/client-generator/src/generators/__tests__/cli.test.ts index d90ec63684..dca2675f2f 100644 --- a/packages/client-generator/src/generators/__tests__/cli.test.ts +++ b/packages/client-generator/src/generators/__tests__/cli.test.ts @@ -1,5 +1,5 @@ import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js'; -import { cliGenerator, cliSample } from '../cli.js'; +import { cliGenerator, cliSample } from '../cli/index.js'; import { builtinGenerators, validateGenerators } from '../index.js'; const STRING: SchemaModel = { kind: 'scalar', scalar: 'string' }; 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 0f9f4dcfc3..c9ef73fc39 100644 --- a/packages/client-generator/src/generators/__tests__/generator-skills.test.ts +++ b/packages/client-generator/src/generators/__tests__/generator-skills.test.ts @@ -6,25 +6,37 @@ import { fileURLToPath } from 'node:url'; // into their user-repo equivalents (plain .mjs, importable straight from scripts/). import { ejectedSkill } from '../../../scripts/ejected-skill.mjs'; -// Skill-first development: every language generator lives in a folder with its own -// AGENTS.md — the design the code must match (and the file eject ships to users). -// A generator folder without a skill, or a skill missing its modify-loop anchors, -// fails here. +// Skill-first development: EVERY generator lives in a folder with its own AGENTS.md — +// the design the code must match. A generator folder without a skill, or a skill +// missing its modify-loop anchors, fails here. const generatorsDir = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -describe.each(['python', 'go', 'php'])('%s generator skill', (name) => { +/** Generators whose whole implementation is one file, so eject ships them. */ +const EJECTABLE = ['python', 'go', 'php']; +/** TypeScript-emitting generators: thin entries over the shared emitters. */ +const TYPESCRIPT = ['sdk', 'zod', 'mock', 'cli', 'swr', 'tanstack-query', 'transformers']; + +describe.each([...EJECTABLE, ...TYPESCRIPT])('%s generator skill', (name) => { const skillPath = join(generatorsDir, name, 'AGENTS.md'); it('exists next to the generator', () => { expect(existsSync(skillPath)).toBe(true); }); - it('names its runtime, the skill-first rule, and the verify loop', () => { + it('states the skill-first rule and how to verify a change', () => { const skill = readFileSync(skillPath, 'utf-8'); - expect(skill).toContain(`${name}-runtime/`); expect(skill).toContain('edit this skill first'); + expect(skill).toContain('## The modify loop'); expect(skill).toContain('large-descriptions.test.ts'); }); +}); + +describe.each(EJECTABLE)('%s generator skill ships to users', (name) => { + const skillPath = join(generatorsDir, name, 'AGENTS.md'); + + it('names its runtime', () => { + expect(readFileSync(skillPath, 'utf-8')).toContain(`${name}-runtime/`); + }); it('is what eject ships — the prepared asset is the user-repo transform of the source', () => { // `prepare` rewrites the skill for the user's repo (their file is generators/.mjs, @@ -39,3 +51,11 @@ describe.each(['python', 'go', 'php'])('%s generator skill', (name) => { expect(shipped).not.toContain('vitest'); }); }); + +describe.each(TYPESCRIPT)('%s generator skill (not ejectable)', (name) => { + it('points at the emitters that implement it and at the customization path', () => { + const skill = readFileSync(join(generatorsDir, name, 'AGENTS.md'), 'utf-8'); + expect(skill).toContain('## Emitters that implement it'); + expect(skill).toContain('Not ejectable'); + }); +}); diff --git a/packages/client-generator/src/generators/__tests__/index.test.ts b/packages/client-generator/src/generators/__tests__/index.test.ts index 70ec3319f2..621d300add 100644 --- a/packages/client-generator/src/generators/__tests__/index.test.ts +++ b/packages/client-generator/src/generators/__tests__/index.test.ts @@ -2,8 +2,8 @@ import { logger } from '@redocly/openapi-core'; import { NotSupportedError } from '../../errors.js'; import { builtinGenerators, validateGenerators } from '../index.js'; -import { sdkGenerator } from '../sdk.js'; -import { zodGenerator } from '../zod.js'; +import { sdkGenerator } from '../sdk/index.js'; +import { zodGenerator } from '../zod/index.js'; describe('builtinGenerators', () => { it('registers the sdk generator descriptor', () => { diff --git a/packages/client-generator/src/generators/__tests__/mock.test.ts b/packages/client-generator/src/generators/__tests__/mock.test.ts index c578eca04e..53a00049ff 100644 --- a/packages/client-generator/src/generators/__tests__/mock.test.ts +++ b/packages/client-generator/src/generators/__tests__/mock.test.ts @@ -1,5 +1,5 @@ import { apiModel, namedSchema, operation, response } from '../../emitters/__tests__/fixtures.js'; -import { mockGenerator } from '../mock.js'; +import { mockGenerator } from '../mock/index.js'; describe('mockGenerator', () => { it('returns [] for a model with no operations', () => { diff --git a/packages/client-generator/src/generators/__tests__/runtime-embed-freshness.test.ts b/packages/client-generator/src/generators/__tests__/runtime-embed-freshness.test.ts index e8cee9197d..e6c4bb74a8 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 @@ -30,10 +30,9 @@ describe('embedded runtimes match their source files', () => { const dir = join(pkgRoot, '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; for (const name of onDisk) { - expect(PYTHON_RUNTIME_SOURCES[name], `${name}: ${STALE}`).toBe( - readFileSync(join(dir, name), 'utf-8') - ); + expect(embedded[name], `${name}: ${STALE}`).toBe(readFileSync(join(dir, name), 'utf-8')); } }); }); diff --git a/packages/client-generator/src/generators/__tests__/sdk.test.ts b/packages/client-generator/src/generators/__tests__/sdk.test.ts index 4f498978d9..169e3b29ac 100644 --- a/packages/client-generator/src/generators/__tests__/sdk.test.ts +++ b/packages/client-generator/src/generators/__tests__/sdk.test.ts @@ -1,6 +1,6 @@ import { HEADER } from '../../emitters/emit-options.js'; import type { ApiModel } from '../../intermediate-representation/model.js'; -import { sdkGenerator } from '../sdk.js'; +import { sdkGenerator } from '../sdk/index.js'; function apiModel(): ApiModel { return { diff --git a/packages/client-generator/src/generators/__tests__/swr.test.ts b/packages/client-generator/src/generators/__tests__/swr.test.ts index 0af00c52f6..818893d1b0 100644 --- a/packages/client-generator/src/generators/__tests__/swr.test.ts +++ b/packages/client-generator/src/generators/__tests__/swr.test.ts @@ -1,6 +1,6 @@ import { apiModel, operation } from '../../emitters/__tests__/fixtures.js'; import { builtinGenerators } from '../index.js'; -import { swrGenerator } from '../swr.js'; +import { swrGenerator } from '../swr/index.js'; const SERVICES = [ { diff --git a/packages/client-generator/src/generators/__tests__/tanstack-query.test.ts b/packages/client-generator/src/generators/__tests__/tanstack-query.test.ts index ffcc3642b0..b83cb089ed 100644 --- a/packages/client-generator/src/generators/__tests__/tanstack-query.test.ts +++ b/packages/client-generator/src/generators/__tests__/tanstack-query.test.ts @@ -1,6 +1,6 @@ import { apiModel, operation } from '../../emitters/__tests__/fixtures.js'; import { builtinGenerators } from '../index.js'; -import { tanstackQueryGenerator } from '../tanstack-query.js'; +import { tanstackQueryGenerator } from '../tanstack-query/index.js'; const SERVICES = [ { diff --git a/packages/client-generator/src/generators/__tests__/transformers.test.ts b/packages/client-generator/src/generators/__tests__/transformers.test.ts index df137fade4..52b8c2d3c0 100644 --- a/packages/client-generator/src/generators/__tests__/transformers.test.ts +++ b/packages/client-generator/src/generators/__tests__/transformers.test.ts @@ -1,6 +1,6 @@ import { apiModel, namedSchema } from '../../emitters/__tests__/fixtures.js'; import { builtinGenerators } from '../index.js'; -import { transformersGenerator } from '../transformers.js'; +import { transformersGenerator } from '../transformers/index.js'; const EVENT = namedSchema('Event', { kind: 'object', diff --git a/packages/client-generator/src/generators/__tests__/zod.test.ts b/packages/client-generator/src/generators/__tests__/zod.test.ts index 314381615a..d6029cee6a 100644 --- a/packages/client-generator/src/generators/__tests__/zod.test.ts +++ b/packages/client-generator/src/generators/__tests__/zod.test.ts @@ -1,5 +1,5 @@ import { apiModel, namedSchema } from '../../emitters/__tests__/fixtures.js'; -import { zodGenerator } from '../zod.js'; +import { zodGenerator } from '../zod/index.js'; const PET = namedSchema('Pet', { kind: 'object', diff --git a/packages/client-generator/src/generators/cli/AGENTS.md b/packages/client-generator/src/generators/cli/AGENTS.md new file mode 100644 index 0000000000..5f7957b02f --- /dev/null +++ b/packages/client-generator/src/generators/cli/AGENTS.md @@ -0,0 +1,49 @@ +# The `cli` generator — its skill + +This file is the generator's DESIGN and governs our own changes: **to change the +generator, edit this skill first, then make the code match it.** + +## What it emits + +A bin-ready `.cli.ts`: one command per operation over the sdk's instance client, +with `--help`, a `schema ` introspection command, and `--dry-run`. + +## Design decisions that must hold + +- **Argument shape:** path params positional, query params typed `--kebab-name` flags, + JSON bodies via `--json '' | @file | @-` (stdin). +- **Exit codes are a contract:** 0 ok, 1 API error, 2 auth, 3 validation, 4 usage. + Errors print ONE JSON object to stderr so stdout stays pipeable. +- **Credentials come from the environment** (a stem-derived prefix, e.g. + `CLIENT_TOKEN`) or explicit flags; `--dry-run` prints the prepared request with + credentials REDACTED. +- **Co-selection aware:** with `zod` selected, requests validate before the network + (exit 3); without it, the CLI still works. +- Throw-mode only — the exit-code mapping reads thrown `ApiError`s. + +## Emitters that implement it + +`emitters/cli.ts` (commands + module), plus the sdk's operation types. + +## Not ejectable — and the customization path + +`redocly eject-generator` covers the standalone language SDKs (`python`, `go`, `php`), +whose entire generator is one self-contained file. This generator is a thin entry over +the SHARED TypeScript emitters listed above, so handing you a copy of the entry would +hand you nothing to customize. Customize the OUTPUT instead: + +- `client.setup` bakes publisher defaults into the generated client. +- Middleware and `configure()` change behavior at runtime, not at generate time. +- A custom generator (`defineGenerator`) emits your own artifact beside the client. + +Ask for a helper or a knob you're missing rather than working around it — that request +is the roadmap signal. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Change the emitter modules named above (the entry is plumbing — it rarely moves). +3. Verify: `npm run compile`, the emitter unit suites + (`VITEST_SUITE=unit npx vitest run packages/client-generator/src/emitters`), the e2e + suites for this generator, and the large-description bars + (`tests/e2e/generate-client/large-descriptions.test.ts`). diff --git a/packages/client-generator/src/generators/cli.ts b/packages/client-generator/src/generators/cli/index.ts similarity index 85% rename from packages/client-generator/src/generators/cli.ts rename to packages/client-generator/src/generators/cli/index.ts index bf460820f7..b6d13389f2 100644 --- a/packages/client-generator/src/generators/cli.ts +++ b/packages/client-generator/src/generators/cli/index.ts @@ -1,9 +1,9 @@ import { join } from 'node:path'; -import { commandData, renderCliModule } from '../emitters/cli.js'; -import type { OperationModel } from '../intermediate-representation/model.js'; -import { anchor } from './anchor.js'; -import type { CodeSample, Generator, SampleContext } from './types.js'; +import { commandData, renderCliModule } from '../../emitters/cli.js'; +import type { OperationModel } from '../../intermediate-representation/model.js'; +import { anchor } from '../anchor.js'; +import type { CodeSample, Generator, SampleContext } from '../types.js'; /** * The cli generator: a bin-ready `.cli.ts` — a zero-dependency, typed diff --git a/packages/client-generator/src/generators/index.ts b/packages/client-generator/src/generators/index.ts index 7d1f23ee95..f3287b7cee 100644 --- a/packages/client-generator/src/generators/index.ts +++ b/packages/client-generator/src/generators/index.ts @@ -1,16 +1,16 @@ import type { EmitOptions } from '../emitters/emit-options.js'; -import { cliGenerator, cliSample } from './cli.js'; +import { cliGenerator, cliSample } from './cli/index.js'; import { goGenerator, goSample } from './go/index.js'; import { BUILTIN_META, validateSelection, type BuiltinMeta } from './meta.js'; -import { mockGenerator } from './mock.js'; +import { mockGenerator } from './mock/index.js'; import { phpGenerator, phpSample } from './php/index.js'; import { pythonGenerator, pythonSample } from './python/index.js'; -import { sdkGenerator, sdkSample } from './sdk.js'; -import { swrGenerator } from './swr.js'; -import { tanstackQueryGenerator } from './tanstack-query.js'; -import { transformersGenerator } from './transformers.js'; +import { sdkGenerator, sdkSample } from './sdk/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 { zodGenerator } from './zod.js'; +import { zodGenerator } from './zod/index.js'; export type { CustomGenerator, diff --git a/packages/client-generator/src/generators/meta.ts b/packages/client-generator/src/generators/meta.ts index 9734683588..11d2a68148 100644 --- a/packages/client-generator/src/generators/meta.ts +++ b/packages/client-generator/src/generators/meta.ts @@ -18,7 +18,9 @@ function tanstackQuery(framework: 'react' | 'vue' | 'svelte' | 'solid'): Builtin requires: ['sdk'], errorModes: ['throw'], load: () => - import('./tanstack-query.js').then((m) => ({ run: m.tanstackQueryGenerator(framework) })), + import('./tanstack-query/index.js').then((m) => ({ + run: m.tanstackQueryGenerator(framework), + })), }; } @@ -37,15 +39,16 @@ const LANGUAGE_SDK_NOT_APPLICABLE: BuiltinMeta['notApplicable'] = { export const BUILTIN_META: Record = { // sdk is the base client; zod emits a standalone schema module importing nothing from it. sdk: { - load: () => import('./sdk.js').then((m) => ({ run: m.sdkGenerator, sample: m.sdkSample })), + load: () => + import('./sdk/index.js').then((m) => ({ run: m.sdkGenerator, sample: m.sdkSample })), }, - zod: { load: () => import('./zod.js').then((m) => ({ run: m.zodGenerator })) }, + zod: { load: () => import('./zod/index.js').then((m) => ({ run: m.zodGenerator })) }, // transformers import the schema *types* from the sdk entry module (so sdk must run) and // assign `Date` values to those fields, which only type-checks when the sdk types dates as `Date`. transformers: { requires: ['sdk'], dateTypes: ['Date'], - load: () => import('./transformers.js').then((m) => ({ run: m.transformersGenerator })), + load: () => import('./transformers/index.js').then((m) => ({ run: m.transformersGenerator })), }, // tanstack-query wraps the sdk's exported, throw-mode operation functions — present in // both runtime distributions, so no runtime restriction. The framework variants differ @@ -58,19 +61,20 @@ export const BUILTIN_META: Record = { swr: { requires: ['sdk'], errorModes: ['throw'], - load: () => import('./swr.js').then((m) => ({ run: m.swrGenerator })), + load: () => import('./swr/index.js').then((m) => ({ run: m.swrGenerator })), }, // mock emits a standalone MSW handlers/factories module referencing the sdk's types. mock: { requires: ['sdk'], - load: () => import('./mock.js').then((m) => ({ run: m.mockGenerator })), + load: () => import('./mock/index.js').then((m) => ({ run: m.mockGenerator })), }, // cli dispatches through the sdk's instance client and relies on thrown ApiError // for its exit-code mapping, so it is sdk-bound and throw-only. cli: { requires: ['sdk'], errorModes: ['throw'], - load: () => import('./cli.js').then((m) => ({ run: m.cliGenerator, sample: m.cliSample })), + load: () => + import('./cli/index.js').then((m) => ({ run: m.cliGenerator, sample: m.cliSample })), }, // python emits a standalone full Python SDK (httpx) — no TypeScript involved, // so a python-only selection never loads the `typescript` package. diff --git a/packages/client-generator/src/generators/mock/AGENTS.md b/packages/client-generator/src/generators/mock/AGENTS.md new file mode 100644 index 0000000000..583bc9d2c5 --- /dev/null +++ b/packages/client-generator/src/generators/mock/AGENTS.md @@ -0,0 +1,47 @@ +# The `mock` generator — its skill + +This file is the generator's DESIGN and governs our own changes: **to change the +generator, edit this skill first, then make the code match it.** + +## What it emits + +A standalone MSW module: `create()` data factories, `Handler()` / +`ErrorHandler(status, body?)` request handlers, and a `handlers` array. + +## Design decisions that must hold + +- **Two data modes:** `mockData: static` bakes deterministic samples from the schema + (examples/defaults first); `faker` emits `faker.*` calls with a seed (`mockSeed`) so + runs are reproducible. +- **Interpolated identifiers are gated** (`codeIdent`): an operation name or method + reaching a code position is validated, never trusted, even though the pipeline + sanitizes upstream. +- Handlers are opt-in overrides: `ErrorHandler` is NOT in `handlers`. +- The module references the sdk's TYPES only — never its runtime. + +## Emitters that implement it + +`emitters/mock.ts`, `mock-value.ts` (data trees), `faker.ts`, `sample.ts`. + +## Not ejectable — and the customization path + +`redocly eject-generator` covers the standalone language SDKs (`python`, `go`, `php`), +whose entire generator is one self-contained file. This generator is a thin entry over +the SHARED TypeScript emitters listed above, so handing you a copy of the entry would +hand you nothing to customize. Customize the OUTPUT instead: + +- `client.setup` bakes publisher defaults into the generated client. +- Middleware and `configure()` change behavior at runtime, not at generate time. +- A custom generator (`defineGenerator`) emits your own artifact beside the client. + +Ask for a helper or a knob you're missing rather than working around it — that request +is the roadmap signal. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Change the emitter modules named above (the entry is plumbing — it rarely moves). +3. Verify: `npm run compile`, the emitter unit suites + (`VITEST_SUITE=unit npx vitest run packages/client-generator/src/emitters`), the e2e + suites for this generator, and the large-description bars + (`tests/e2e/generate-client/large-descriptions.test.ts`). diff --git a/packages/client-generator/src/generators/mock.ts b/packages/client-generator/src/generators/mock/index.ts similarity index 79% rename from packages/client-generator/src/generators/mock.ts rename to packages/client-generator/src/generators/mock/index.ts index 1f358625f7..f139fff4ec 100644 --- a/packages/client-generator/src/generators/mock.ts +++ b/packages/client-generator/src/generators/mock/index.ts @@ -1,9 +1,9 @@ import { join } from 'node:path'; -import { HEADER } from '../emitters/emit-options.js'; -import { renderMockModule } from '../emitters/mock.js'; -import { anchor } from './anchor.js'; -import type { Generator } from './types.js'; +import { HEADER } from '../../emitters/emit-options.js'; +import { renderMockModule } from '../../emitters/mock.js'; +import { anchor } from '../anchor.js'; +import type { Generator } from '../types.js'; /** * The mock generator: a standalone `.mocks.ts` module of MSW handlers and diff --git a/packages/client-generator/src/generators/sdk/AGENTS.md b/packages/client-generator/src/generators/sdk/AGENTS.md new file mode 100644 index 0000000000..ee22a902bc --- /dev/null +++ b/packages/client-generator/src/generators/sdk/AGENTS.md @@ -0,0 +1,56 @@ +# The `sdk` generator — its skill + +This file is the generator's DESIGN and governs our own changes: **to change the +generator, edit this skill first, then make the code match it** — a diff with no +covering sentence here is incomplete. + +## What it emits + +The typed TypeScript client itself: model types with JSDoc, type guards, the `Ops` +type map, the `OPERATIONS` descriptor table, a `client` instance, flat call sugar, +and either the embedded runtime (`runtime: inline`) or imports from +`@redocly/client-generator` (`runtime: package`). + +## Design decisions that must hold + +- **Descriptor-driven:** generated code is DATA (`OPERATIONS` + `Ops`) plus wiring; + request behavior lives in the runtime, never in per-operation code. + `satisfies Record` is the version-skew guard. +- **`single` vs `split`:** split derives `.schemas.ts` (types, enums, guards) and + an entry that `export *`s it; the entry type-imports only the schema names it + references (`collectEntrySchemaRefs`). +- **Zero runtime dependencies.** `Date`, `Blob`, `fetch` — nothing else. +- **Names are collision-safe:** `packageIdents` seeds every reserved wiring name before + any operation is sanitized, so renames are deterministic (`configure` → `configure_2`). +- **Throw mode returns the body**; `{ envelope: true }` opts into + `{ data, headers, response }` with typed declared headers. Result mode returns + `{ data, error, response }` and ignores `envelope`. + +## Emitters that implement it + +`emitters/client-assembly.ts` (orchestration), `render-client.ts` (Ops, aliases, flat +sugar), `descriptor.ts`, `ts-type.ts`/`ts-literal.ts` (type + data text), `sse.ts`, +`pagination.ts`, `response-headers.ts`, `inline-runtime.ts`, `setup-bake.ts`. + +## Not ejectable — and the customization path + +`redocly eject-generator` covers the standalone language SDKs (`python`, `go`, `php`), +whose entire generator is one self-contained file. This generator is a thin entry over +the SHARED TypeScript emitters listed above, so handing you a copy of the entry would +hand you nothing to customize. Customize the OUTPUT instead: + +- `client.setup` bakes publisher defaults into the generated client. +- Middleware and `configure()` change behavior at runtime, not at generate time. +- A custom generator (`defineGenerator`) emits your own artifact beside the client. + +Ask for a helper or a knob you're missing rather than working around it — that request +is the roadmap signal. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Change the emitter modules named above (the entry is plumbing — it rarely moves). +3. Verify: `npm run compile`, the emitter unit suites + (`VITEST_SUITE=unit npx vitest run packages/client-generator/src/emitters`), the e2e + suites for this generator, and the large-description bars + (`tests/e2e/generate-client/large-descriptions.test.ts`). diff --git a/packages/client-generator/src/generators/sdk.ts b/packages/client-generator/src/generators/sdk/index.ts similarity index 86% rename from packages/client-generator/src/generators/sdk.ts rename to packages/client-generator/src/generators/sdk/index.ts index 2099b58fc5..f9ff8554e6 100644 --- a/packages/client-generator/src/generators/sdk.ts +++ b/packages/client-generator/src/generators/sdk/index.ts @@ -1,10 +1,10 @@ import { join } from 'node:path'; -import { emitClientSingleFile, emitClientSplit } from '../emitters/client-assembly.js'; -import { packageIdents } from '../emitters/descriptor.js'; -import type { OperationModel } from '../intermediate-representation/model.js'; -import { anchor } from './anchor.js'; -import type { CodeSample, Generator, SampleContext } from './types.js'; +import { emitClientSingleFile, emitClientSplit } from '../../emitters/client-assembly.js'; +import { packageIdents } from '../../emitters/descriptor.js'; +import type { OperationModel } from '../../intermediate-representation/model.js'; +import { anchor } from '../anchor.js'; +import type { CodeSample, Generator, SampleContext } from '../types.js'; /** * The default generator: the full typed client (model types + runtime + endpoints). diff --git a/packages/client-generator/src/generators/swr/AGENTS.md b/packages/client-generator/src/generators/swr/AGENTS.md new file mode 100644 index 0000000000..77f486e0a7 --- /dev/null +++ b/packages/client-generator/src/generators/swr/AGENTS.md @@ -0,0 +1,46 @@ +# The `swr` generator — its skill + +This file is the generator's DESIGN and governs our own changes: **to change the +generator, edit this skill first, then make the code match it.** + +## What it emits + +React SWR hooks over the sdk's exported operation functions: `use()` with a +`Key()` key factory for queries, `useSWRMutation` for mutations. + +## Design decisions that must hold + +- **Wraps the sdk's functions** — it never re-implements requests, so it requires `sdk` + and is throw-mode only. +- **Keys are exported factories** so consumers can invalidate precisely. +- **`envelope` is excluded** from hook options (`Omit`) and + stripped from the forwarded call: cached data is always the plain body. +- **Skips what it cannot wrap** — SSE operations and `Variables` name collisions — + with a warning naming each one, never silently. + +## Emitters that implement it + +`emitters/swr.ts`, `wrapper-support.ts` (shared wrappable-operation policy). + +## Not ejectable — and the customization path + +`redocly eject-generator` covers the standalone language SDKs (`python`, `go`, `php`), +whose entire generator is one self-contained file. This generator is a thin entry over +the SHARED TypeScript emitters listed above, so handing you a copy of the entry would +hand you nothing to customize. Customize the OUTPUT instead: + +- `client.setup` bakes publisher defaults into the generated client. +- Middleware and `configure()` change behavior at runtime, not at generate time. +- A custom generator (`defineGenerator`) emits your own artifact beside the client. + +Ask for a helper or a knob you're missing rather than working around it — that request +is the roadmap signal. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Change the emitter modules named above (the entry is plumbing — it rarely moves). +3. Verify: `npm run compile`, the emitter unit suites + (`VITEST_SUITE=unit npx vitest run packages/client-generator/src/emitters`), the e2e + suites for this generator, and the large-description bars + (`tests/e2e/generate-client/large-descriptions.test.ts`). diff --git a/packages/client-generator/src/generators/swr.ts b/packages/client-generator/src/generators/swr/index.ts similarity index 84% rename from packages/client-generator/src/generators/swr.ts rename to packages/client-generator/src/generators/swr/index.ts index 916a118ca9..0cc86c6fbc 100644 --- a/packages/client-generator/src/generators/swr.ts +++ b/packages/client-generator/src/generators/swr/index.ts @@ -1,9 +1,9 @@ import { join } from 'node:path'; -import { HEADER } from '../emitters/emit-options.js'; -import { renderSwrModule } from '../emitters/swr.js'; -import { anchor } from './anchor.js'; -import type { Generator } from './types.js'; +import { HEADER } from '../../emitters/emit-options.js'; +import { renderSwrModule } from '../../emitters/swr.js'; +import { anchor } from '../anchor.js'; +import type { Generator } from '../types.js'; /** * The swr generator: a standalone `.swr.ts` module of SWR hooks wrapping the diff --git a/packages/client-generator/src/generators/tanstack-query/AGENTS.md b/packages/client-generator/src/generators/tanstack-query/AGENTS.md new file mode 100644 index 0000000000..78fd453893 --- /dev/null +++ b/packages/client-generator/src/generators/tanstack-query/AGENTS.md @@ -0,0 +1,48 @@ +# The `tanstack-query` generator — its skill + +This file is the generator's DESIGN and governs our own changes: **to change the +generator, edit this skill first, then make the code match it.** + +## What it emits + +Query/mutation option factories for TanStack Query — `Options()`, +`Mutation()`, and `InfiniteOptions()` for paginated operations — plus exported +query keys. One generator, four framework variants (`react` default, `-vue`, +`-svelte`, `-solid`) differing only in the imported package. + +## Design decisions that must hold + +- **Options factories, not hooks:** consumers call `useQuery(Options(...))`, so the + output works with any of the framework adapters and stays testable. +- **`queryKeyPrefix`** namespaces every key when several clients share a cache. +- **Infinite queries** derive `getNextPageParam` from the resolved pagination rule; a + `link`-style rule reads the `Link` header the descriptor declares. +- **`envelope` is excluded and stripped** — cached data is the plain body. +- Requires `sdk`; throw-mode only (it wraps thrown errors into query errors). + +## Emitters that implement it + +`emitters/tanstack-query.ts`, `wrapper-support.ts`, `pagination.ts`. + +## Not ejectable — and the customization path + +`redocly eject-generator` covers the standalone language SDKs (`python`, `go`, `php`), +whose entire generator is one self-contained file. This generator is a thin entry over +the SHARED TypeScript emitters listed above, so handing you a copy of the entry would +hand you nothing to customize. Customize the OUTPUT instead: + +- `client.setup` bakes publisher defaults into the generated client. +- Middleware and `configure()` change behavior at runtime, not at generate time. +- A custom generator (`defineGenerator`) emits your own artifact beside the client. + +Ask for a helper or a knob you're missing rather than working around it — that request +is the roadmap signal. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Change the emitter modules named above (the entry is plumbing — it rarely moves). +3. Verify: `npm run compile`, the emitter unit suites + (`VITEST_SUITE=unit npx vitest run packages/client-generator/src/emitters`), the e2e + suites for this generator, and the large-description bars + (`tests/e2e/generate-client/large-descriptions.test.ts`). diff --git a/packages/client-generator/src/generators/tanstack-query.ts b/packages/client-generator/src/generators/tanstack-query/index.ts similarity index 87% rename from packages/client-generator/src/generators/tanstack-query.ts rename to packages/client-generator/src/generators/tanstack-query/index.ts index 7a41664148..8e718bdef5 100644 --- a/packages/client-generator/src/generators/tanstack-query.ts +++ b/packages/client-generator/src/generators/tanstack-query/index.ts @@ -1,9 +1,9 @@ import { join } from 'node:path'; -import { HEADER } from '../emitters/emit-options.js'; -import { renderTanstackModule } from '../emitters/tanstack-query.js'; -import { anchor } from './anchor.js'; -import type { Generator } from './types.js'; +import { HEADER } from '../../emitters/emit-options.js'; +import { renderTanstackModule } from '../../emitters/tanstack-query.js'; +import { anchor } from '../anchor.js'; +import type { Generator } from '../types.js'; /** * The tanstack-query generator: a standalone `.tanstack.ts` module of diff --git a/packages/client-generator/src/generators/transformers/AGENTS.md b/packages/client-generator/src/generators/transformers/AGENTS.md new file mode 100644 index 0000000000..7a650a5c73 --- /dev/null +++ b/packages/client-generator/src/generators/transformers/AGENTS.md @@ -0,0 +1,45 @@ +# The `transformers` generator — its skill + +This file is the generator's DESIGN and governs our own changes: **to change the +generator, edit this skill first, then make the code match it.** + +## What it emits + +Per-schema `to()` / `from()` converters that turn wire JSON into typed +values and back — the bridge for `dateType: Date` clients. + +## Design decisions that must hold + +- **Requires `dateType: Date`** (declared as `dateTypes: ['Date']`, so a mismatched + selection fails fast): the converters assign `Date` objects to fields the sdk types as + `Date`, which only type-checks in that mode. +- **Imports the sdk's schema TYPES** (so `sdk` is required) and nothing else. +- Converters are pure and total: every named schema gets a pair, nested structures + recurse, and a missing optional stays missing. + +## Emitters that implement it + +`emitters/transformers.ts`. + +## Not ejectable — and the customization path + +`redocly eject-generator` covers the standalone language SDKs (`python`, `go`, `php`), +whose entire generator is one self-contained file. This generator is a thin entry over +the SHARED TypeScript emitters listed above, so handing you a copy of the entry would +hand you nothing to customize. Customize the OUTPUT instead: + +- `client.setup` bakes publisher defaults into the generated client. +- Middleware and `configure()` change behavior at runtime, not at generate time. +- A custom generator (`defineGenerator`) emits your own artifact beside the client. + +Ask for a helper or a knob you're missing rather than working around it — that request +is the roadmap signal. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Change the emitter modules named above (the entry is plumbing — it rarely moves). +3. Verify: `npm run compile`, the emitter unit suites + (`VITEST_SUITE=unit npx vitest run packages/client-generator/src/emitters`), the e2e + suites for this generator, and the large-description bars + (`tests/e2e/generate-client/large-descriptions.test.ts`). diff --git a/packages/client-generator/src/generators/transformers.ts b/packages/client-generator/src/generators/transformers/index.ts similarity index 85% rename from packages/client-generator/src/generators/transformers.ts rename to packages/client-generator/src/generators/transformers/index.ts index cbdb34f786..cb71f963d3 100644 --- a/packages/client-generator/src/generators/transformers.ts +++ b/packages/client-generator/src/generators/transformers/index.ts @@ -1,9 +1,9 @@ import { join } from 'node:path'; -import { HEADER } from '../emitters/emit-options.js'; -import { renderTransformersModule } from '../emitters/transformers.js'; -import { anchor } from './anchor.js'; -import type { Generator } from './types.js'; +import { HEADER } from '../../emitters/emit-options.js'; +import { renderTransformersModule } from '../../emitters/transformers.js'; +import { anchor } from '../anchor.js'; +import type { Generator } from '../types.js'; /** * The transformers generator: a standalone `.transformers.ts` module of diff --git a/packages/client-generator/src/generators/zod/AGENTS.md b/packages/client-generator/src/generators/zod/AGENTS.md new file mode 100644 index 0000000000..d6512bc88b --- /dev/null +++ b/packages/client-generator/src/generators/zod/AGENTS.md @@ -0,0 +1,45 @@ +# The `zod` generator — its skill + +This file is the generator's DESIGN and governs our own changes: **to change the +generator, edit this skill first, then make the code match it.** + +## What it emits + +A standalone `.zod.ts`: one `export const Schema` per named IR schema, the +`operationSchemas` request/response map, and a `zodValidation()` middleware. + +## Design decisions that must hold + +- **The client stays dependency-free.** zod is the CONSUMER's peer dependency; the + generated client never imports this module, and this module never imports the client. +- **Output-mode-agnostic:** one module beside the client whatever the sdk's layout. +- **Emits nothing** when the model has neither named schemas nor JSON operation bodies — + an empty file is worse than no file. +- Validation is opt-in at runtime (`use(zodValidation())`), never automatic. + +## Emitters that implement it + +`emitters/zod.ts` (schema expressions + module assembly). + +## Not ejectable — and the customization path + +`redocly eject-generator` covers the standalone language SDKs (`python`, `go`, `php`), +whose entire generator is one self-contained file. This generator is a thin entry over +the SHARED TypeScript emitters listed above, so handing you a copy of the entry would +hand you nothing to customize. Customize the OUTPUT instead: + +- `client.setup` bakes publisher defaults into the generated client. +- Middleware and `configure()` change behavior at runtime, not at generate time. +- A custom generator (`defineGenerator`) emits your own artifact beside the client. + +Ask for a helper or a knob you're missing rather than working around it — that request +is the roadmap signal. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Change the emitter modules named above (the entry is plumbing — it rarely moves). +3. Verify: `npm run compile`, the emitter unit suites + (`VITEST_SUITE=unit npx vitest run packages/client-generator/src/emitters`), the e2e + suites for this generator, and the large-description bars + (`tests/e2e/generate-client/large-descriptions.test.ts`). diff --git a/packages/client-generator/src/generators/zod.ts b/packages/client-generator/src/generators/zod/index.ts similarity index 82% rename from packages/client-generator/src/generators/zod.ts rename to packages/client-generator/src/generators/zod/index.ts index 2c93b5fa97..981dba25bb 100644 --- a/packages/client-generator/src/generators/zod.ts +++ b/packages/client-generator/src/generators/zod/index.ts @@ -1,9 +1,9 @@ import { join } from 'node:path'; -import { HEADER } from '../emitters/emit-options.js'; -import { renderZodModule } from '../emitters/zod.js'; -import { anchor } from './anchor.js'; -import type { Generator } from './types.js'; +import { HEADER } from '../../emitters/emit-options.js'; +import { renderZodModule } from '../../emitters/zod.js'; +import { anchor } from '../anchor.js'; +import type { Generator } from '../types.js'; /** * The zod generator: a standalone `.zod.ts` module of Zod schemas (one From ee02eeae763fabec8d73e613e13e8a76861cf68b Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 5 Aug 2026 17:23:59 +0300 Subject: [PATCH 086/211] docs: specify the target behavior from PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docs-as-spec: these describe the intended end state, and the implementation commits that follow make the code match. Covers RomanHotsiy's review — every generator ejectable, design shipped as an agent skill, semver-based generator compatibility, x-redoclyPagination casing, one authoring toolkit with no typescript dependency, generator-declared options, no inline generator list, CLI validation by default, symmetric language sections with a single differences table — and adamaltman's per-language findings that are documentation asks (auth shapes, middleware contracts, reserved-word fields). --- .changeset/agent-friendly-generators.md | 4 +- docs/@v2/commands/eject-generator.md | 47 +++---- docs/@v2/commands/generate-client.md | 5 +- docs/@v2/configuration/reference/client.md | 11 +- .../@v2/guides/customize-client-generation.md | 56 +++++++-- docs/@v2/guides/use-generated-client.md | 116 ++++++++++++++---- 6 files changed, 169 insertions(+), 70 deletions(-) diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index e32f7cda15..836facd993 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -3,6 +3,4 @@ '@redocly/cli': minor --- -Added agent-friendly client generation: built-in `python`, `go`, `php`, and `cli` generators, a language-neutral authoring toolkit with a per-generator `AGENTS.md` skill, an `eject-generator` command, `x-codeSamples` output, and verification against large real-world descriptions — with every generator now emitting through source-text templates. - -**Note:** the AST exports (`ts`, `printStatements`, `schemaToTypeNode`, …) were removed from `@redocly/client-generator/generate` in favor of the text toolkit (`tsType`, `tsJsdoc`, `codeLiteral`). +Added agent-friendly client generation: built-in `python`, `go`, `php`, and `cli` generators, a language-neutral authoring toolkit, an `eject-generator` command that vendors any built-in generator into your repo together with its design as an agent skill, and verification against large real-world descriptions. diff --git a/docs/@v2/commands/eject-generator.md b/docs/@v2/commands/eject-generator.md index 764651b468..27fdf16613 100644 --- a/docs/@v2/commands/eject-generator.md +++ b/docs/@v2/commands/eject-generator.md @@ -5,44 +5,37 @@ The `eject-generator` command vendors a built-in client generator into your repo as an editable file — the generator becomes yours to customize, while the _generated_ client stays machine-owned and reproducible. Your agent (or you) edits the generator, `redocly generate-client` rebuilds the client, and next week's spec change regenerates with the customization intact. -Ejectable generators: `python`, `go`, `php` — the language generators built on the language-neutral authoring toolkit. -The TypeScript `sdk` and its satellite generators are customized through `client.setup`, middleware, and configuration instead; running `eject-generator sdk` prints that guidance. +Every built-in generator can be ejected: the language SDKs (`python`, `go`, `php`), the TypeScript `sdk`, and the satellites (`zod`, `mock`, `cli`, `swr`, `tanstack-query`, `transformers`). ## Usage ```bash redocly eject-generator python -redocly eject-generator go --dir ./generators +redocly eject-generator zod --dir ./generators redocly eject-generator php --update redocly eject-generator php --force ``` ## Options -| Option | Type | Description | -| ---------- | ------- | ---------------------------------------------------------------------------------------------------- | -| generator | string | Built-in generator to eject: `python`, `go`, or `php`. | -| `--dir` | string | Directory to eject into. Default `./generators`. | -| `--update` | boolean | Three-way merge a newer generator version into your customized copy; conflicts get standard markers. | -| `--force` | boolean | Overwrite an existing ejected file, discarding local edits. | +| Option | Type | Description | +| ---------- | ------- | ------------------------------------------------------------------------------------------------------- | +| generator | string | Built-in generator to eject. | +| `--dir` | string | Directory to eject into. Default `./generators`. | +| `--update` | boolean | Three-way merge the current built-in version into your customized copy; conflicts get standard markers. | +| `--force` | boolean | Overwrite an existing ejected file, discarding local edits. | ## How it works -Ejecting writes four things: +Ejecting writes two things: -- `/.mjs` — the generator, the exact code the built-in runs, readable plain ESM. -- `/.pristine/.mjs` — a pristine snapshot (commit it); `--update` uses it as the merge base. -- `/AGENTS.md` — the generator-authoring guide for your coding agent (the contract, the model shape, the helper library), shared by every ejected generator and marker-delimited so your own additions survive refreshes. -- `/.AGENTS.md` — this generator's own design doc: the decisions its code implements and the modify loop (edit the design first, then make the code match). - It's dropped once and then it's yours — evolve it with your customizations. +- `/.mjs` — the generator itself, as plain ESM you own. It imports the authoring toolkit from `@redocly/client-generator` and contains everything else it needs, so it runs standalone. +- `.claude/skills/-generator/SKILL.md` — the generator's design as an agent skill: the decisions its code implements, and the loop to follow when changing it (state the change in the skill, then make the code match). + Coding agents load skills automatically, so your agent starts from the design instead of reverse-engineering the code. -The ejected file imports the authoring toolkit, so install it once: +A first eject also drops `.claude/skills/client-generator-authoring/SKILL.md` — the shared authoring guide (the generator contract, the API model, the helper library). That file is refreshed on later ejects; anything you add outside its markers survives. -```bash -npm install --save-dev @redocly/client-generator -``` - -Then point your config at the file — a path entry takes over the built-in name: +Eject wires itself up: it adds `@redocly/client-generator` to your `devDependencies` if it isn't there and points your config at the file, where a path entry takes over the built-in name. ```yaml client: @@ -51,5 +44,13 @@ client: ``` An ejected-unmodified generator produces byte-identical output to the built-in. -To roll back, delete the file and restore the config line. -Not ejected means managed: without ejecting, generator improvements arrive via `npm update` with nothing to merge. +To roll back, delete the file and the config line. + +## Updating an ejected generator + +`redocly eject-generator --update` merges the version shipped by your installed `@redocly/client-generator` into your copy. +The three-way merge uses the version recorded in the ejected file's header as the common ancestor, so nothing extra needs to be committed and there is no snapshot to keep in sync. +Conflicts arrive as standard `<<<<<<<` markers for you to resolve. + +Ejected generators keep working across CLI upgrades as long as the authoring contract they were written against is compatible. +The contract follows the `@redocly/client-generator` version: a breaking change bumps the major version (the minor, while the package is `0.x`), and a generator ejected from an incompatible version fails upfront with the version it expects, the version you have, and the `--update` command to reconcile them. diff --git a/docs/@v2/commands/generate-client.md b/docs/@v2/commands/generate-client.md index dffde50bba..af97cf343f 100644 --- a/docs/@v2/commands/generate-client.md +++ b/docs/@v2/commands/generate-client.md @@ -40,7 +40,7 @@ redocly generate-client [--help] [--version] | `--output-mode` | string | File layout. See [Choose an output mode](#choose-an-output-mode).
**Possible values:** `single`, `split`. Default value is `single`. | | `--runtime` | string | Where the client's engine lives. See [Choose a runtime](#choose-a-runtime).
**Possible values:** `inline`, `package`. Default value is `inline`. | | `--import-ext` | string | Extension in generated relative imports. See [Run with Node directly](../guides/use-generated-client.md#run-with-node-directly).
**Possible values:** `js` (the tsc/bundler convention), `ts` (for Node's built-in type stripping). Default value is `js`. | -| `--generator` | [string] | Generator to run — a built-in name (`tanstack-query` also has `-vue`/`-svelte`/`-solid` variants; `python`/`go`/`php` emit full Python, Go, and PHP SDKs; `cli` emits a command-line interface) or a custom generator's path or package; repeat the flag to run several. Default value is `sdk`. See [Generators](../guides/use-generated-client.md#generators). | +| `--generator` | [string] | Generator to run: a built-in name, or a custom generator's path or package. Repeat the flag to run several. Default value is `sdk`. See [Generators](../guides/use-generated-client.md#generators) for the full list. | | `--args-style` | string | How operation inputs are passed. See [Argument style](../guides/use-generated-client.md#argument-style).
**Possible values:** `flat`, `grouped`. Default value is `flat`. | | `--error-mode` | string | How operations report HTTP errors. See [Error handling](../guides/use-generated-client.md#error-handling).
**Possible values:** `throw`, `result`. Default value is `throw`. | | `--date-type` | string | Type of `date`/`date-time` fields; pair `Date` with the `transformers` generator.
**Possible values:** `string`, `Date`. Default value is `string`. | @@ -58,8 +58,7 @@ redocly generate-client [--help] [--version] Instead of passing flags every time, keep the settings in `redocly.yaml` under a top-level `client` block and per-API `apis..client` / `clientOutput` — see the [`client` configuration reference](../configuration/reference/client.md) for the fields. CLI flags take precedence over the configuration. -Auto-pagination has no CLI flag; it's declared only as [`client.pagination`](../configuration/reference/client.md#pagination-object) configuration or the `x-redocly-pagination` operation extension. -Code samples for docs are config-only too: [`client.codeSamples`](../configuration/reference/client.md) emits an `x-codeSamples` overlay next to the client. +Auto-pagination has no CLI flag; it's declared only as [`client.pagination`](../configuration/reference/client.md#pagination-object) configuration or the `x-redoclyPagination` operation extension. ```yaml client: diff --git a/docs/@v2/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md index 05ae2118dd..bf859cd18e 100644 --- a/docs/@v2/configuration/reference/client.md +++ b/docs/@v2/configuration/reference/client.md @@ -15,7 +15,7 @@ The input and output are not part of the `client` block: Each scalar option mirrors the matching CLI flag and shares its default — see the [command options](../../commands/generate-client.md#options) for the full description of each value. The `pagination` option is config-only — a structured, durable contract that belongs in versioned configuration rather than a shell string. -For runs without a configuration file, declare pagination per operation with the `x-redocly-pagination` extension in the description, or pass `pagination` to the programmatic `generateClient(...)`. +For runs without a configuration file, declare pagination per operation with the `x-redoclyPagination` extension in the description, or pass `pagination` to the programmatic `generateClient(...)`. | Option | Type | Description | | ---------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -31,6 +31,9 @@ For runs without a configuration file, declare pagination per operation with the | `queryKeyPrefix` | string | Leading element for every `tanstack-query` query/mutation key — namespaces the cache when several generated APIs share one QueryClient. Config-only, no flag. | | `codeSamples` | boolean | Emit `.code-samples.yaml` — an OpenAPI Overlay adding per-operation `x-codeSamples` collected from every selected generator that implements `sample()`. Config-only, no flag. | | `serverUrl` | string | Server URL included in the client as its default; falls back to `servers[0].url`. | +| `goPackage` | string | Package clause for the `go` generator's output. Default `client`. | +| `binName` | string | Command name the `cli` generator uses in help output and to derive its credential environment variables. Defaults to the output stem, sanitized. | +| `options` | object | Per-generator options, keyed by generator name — validated against the schema a generator declares. See [Custom generators](../../guides/customize-client-generation.md#custom-generators). | | `setup` | string | Path to a publisher setup module that gets included in the client — pre-configures defaults such as the server URL, retries, headers, and middleware. See [Publisher defaults](../../guides/customize-client-generation.md#publisher-defaults). | | `pagination` | [Pagination object](#pagination-object) | Declares how the API paginates, so paginated operations gain typed `.pages()`/`.items()` async iterators. | @@ -49,12 +52,12 @@ See [Pagination in the usage guide](../../guides/use-generated-client.md#paginat | `limitParam` | string | Optional page-size query parameter for any style; recorded for tooling — the iterator never sets it. | | `items` | string | **REQUIRED**. JSON pointer to the page's item array in the response; use `''` when the response body is the item array itself. | | `exclude` | [string] | operationIds that no source may paginate; wins over overrides, extensions, and the convention. | -| `operations` | map of operationId → rule | Per-operation rules taking the same fields as the convention; each entry beats the description's `x-redocly-pagination` and the convention. | +| `operations` | map of operationId → rule | Per-operation rules taking the same fields as the convention; each entry beats the description's `x-redoclyPagination` and the convention. | The rules are verified at generate time: the advance parameter must be a declared query parameter of the right type (string for `cursor`, numeric for `offset` and `page`), and the JSON pointers must resolve in the operation's JSON success-response schema, with `items` landing on an array and `hasMore` on a boolean. A convention that doesn't fit an operation skips it; an explicit rule that doesn't fit fails generation. -The `x-redocly-pagination` operation extension in the API description takes the same rule fields. -Per operation, precedence is `operations[id]`, then `x-redocly-pagination`, then the convention. +The `x-redoclyPagination` operation extension in the API description takes the same rule fields. +Per operation, precedence is `operations[id]`, then `x-redoclyPagination`, then the convention. ## Examples diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index cbcd6b187a..324b24a225 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -64,10 +64,12 @@ See the [`baked-setup` example](https://github.com/Redocly/redocly-cli/tree/main ## Eject The fastest path to a customized generator is -[`redocly eject-generator `](../commands/eject-generator.md): it vendors a built-in language generator (`python`, `go`, `php`) into `./generators/` as an editable file, with a pristine snapshot for [three-way updates](../commands/eject-generator.md#how-it-works) and the `AGENTS.md` authoring guide for your coding agent. +[`redocly eject-generator `](../commands/eject-generator.md): it vendors any built-in generator into `./generators/` as an editable file you own. An ejected-unmodified generator produces byte-identical output, and the path entry takes over the built-in name — regeneration survives every customization. +[`--update`](../commands/eject-generator.md#updating-an-ejected-generator) merges later built-in versions into your copy. -Eject drops two guides next to the generator: the shared `AGENTS.md` authoring guide (the model shape, the helper library, and the verify loop — edit the generator → `redocly generate-client` → review the client diff; generated files are never hand-edited) and the generator's own `.AGENTS.md` design doc, which your agent treats as the source of truth: state the change there first, then make the code match. +Eject also writes the generator's design as an agent skill (`.claude/skills/-generator/SKILL.md`) plus the shared authoring skill. +Your agent treats the design as the source of truth: state the change there first, then make the code match — and never hand-edit generated output, only the generator. ## Custom generators @@ -76,10 +78,38 @@ For anything else derived from the same description (validators in another libra A generator adds artifacts _next to_ the client — it doesn't change the generated client's behavior; for that, use [publisher defaults](#publisher-defaults) or let the consumer compose [middleware](./use-generated-client.md#middleware). A generator is `{ name, run }` (plus optional compatibility metadata); author it with `defineGenerator` from the package root. +The output is text, so a generator can emit **any language** — Python models, a Go client, a permissions matrix. Emitted file paths must stay inside the `--output` directory — subdirectories are fine, escapes are rejected. -A generator may declare `contract` (the `GENERATOR_CONTRACT` number exported by `@redocly/client-generator`); when a future CLI changes the model shape incompatibly, the mismatch then fails upfront with the fix path instead of producing wrong output. -Ejected generators declare it automatically. -The output is text, so a generator can emit **any language** — Python models, a Go client, a permissions matrix — not just TypeScript. + +**Compatibility follows the `@redocly/client-generator` version.** +The API model and the helper library are the generator contract, and it changes under semver: a breaking change bumps the major version (the minor, while the package is `0.x`). +Declare the version you authored against with `requiresGenerator: '^1.2.0'`, and an incompatible CLI fails upfront — naming the version it has, the version you need, and the upgrade — instead of feeding your generator a model shape it doesn't expect. +Ejected generators record it for you. + +**A generator can declare its own options** with a JSON Schema, so publishers configure it the way they configure the built-ins: + +```js +export default defineGenerator({ + name: 'permissions-matrix', + options: { + type: 'object', + properties: { groupBy: { enum: ['tag', 'path'], default: 'tag' } }, + additionalProperties: false, + }, + run({ model, outputPath, options }) { + // `options` is validated against the schema before `run` is called. + }, +}); +``` + +```yaml +client: + generators: + - ./tools/permissions-matrix.mjs + options: + permissions-matrix: + groupBy: path +``` ### Language-neutral helpers @@ -95,18 +125,20 @@ The package root exports pure helpers over the API model that cover the cross-la | `Printer` | Indentation-aware text builder — no manual whitespace bookkeeping. | | `docText(description)` | Description text 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 applying to an operation (per-op config > `x-redocly-pagination` > fitting convention), normalized. | +| `paginationRuleFor(op, config)` | The pagination rule applying to an operation (per-op config > `x-redoclyPagination` > fitting convention), normalized. | -A generator that imports only these helpers (and not the TypeScript toolkit below) runs without the `typescript` package installed. +These helpers plus `Printer` are the ONE way to author a generator, in any output language. +Nothing in the authoring path depends on the `typescript` package, so a generator also runs in the browser or any other embedded host. -For a repo-local, agent-readable version of this guidance, copy the [`AGENTS.md` template](https://github.com/Redocly/redocly-cli/blob/main/packages/client-generator/eject-assets/AGENTS.md) into your generators directory — it gives any coding agent the contract, the model reference, and this helper table. +`redocly eject-generator ` writes this guidance into your repo as an agent skill, so your coding agent has the contract, the model reference, and this helper table without being told. ### TypeScript artifacts -For TypeScript output, render types with the text toolkit from `@redocly/client-generator/generate` — `tsType` is the same schema→type renderer the built-in sdk uses, so the mapping (refs, arrays, unions, formats, parenthesization) matches the generated client exactly: +TypeScript is just another output language: the same package root exports the TypeScript-specific renderers beside the neutral helpers. +`tsType` is the schema→type renderer the built-in sdk itself uses, so the mapping (refs, arrays, unions, formats, parenthesization) matches the generated client exactly: ```js -import { tsType } from '@redocly/client-generator/generate'; +import { tsType } from '@redocly/client-generator'; export default { name: 'response-map', @@ -128,8 +160,8 @@ export default { }; ``` -The toolkit exports `tsType`, `tsJsdoc`, `codeLiteral`, `operationSignature`, `pascalCase`, and more; the package root exports the model (IR) types and the language-neutral helpers. -For a trivial artifact, returning a plain string as `content` works too — no toolkit required. +The package root exports `tsType`, `tsJsdoc`, `codeLiteral`, `operationSignature`, and `pascalCase` alongside the model (IR) types and the neutral helpers — one import path for everything. +For a trivial artifact, returning a plain string as `content` works too. Select a generator in `redocly.yaml` by path or package name: diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index 7866857b10..fc48fe9499 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -31,7 +31,7 @@ See the [`zod`](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/gener The `cli` generator emits `.cli.ts` — a zero-dependency, bin-ready command-line interface over the generated client. Path params are positional, query params become typed `--kebab-name` flags (enums list their choices in `--help`, array params repeat the flag), and JSON request bodies arrive via `--json ''`, `--json @file.json`, or `--json @-` (stdin). -When `zod` is co-selected, requests are validated before they are sent. +Requests are validated before they are sent — the `cli` generator brings the validation it needs, so no extra generator has to be selected. ```sh redocly generate-client openapi.yaml --output src/client.ts --generator sdk --generator cli @@ -56,32 +56,27 @@ Exit codes are a documented contract, and errors print one JSON object to stderr To ship it as a real bin, compile with `tsc` and point `package.json`'s `bin` at the compiled file. -### Python SDK +The CLI can also emit its own reference documentation as Markdown (every command, flag, and exit code) — planned next, and then for the language SDKs too. -The `python` generator emits a self-contained `.py` next to the configured output — a full Python SDK over [httpx](https://www.python-httpx.org/) (`pip install httpx`, Python ≥ 3.9): -typed dataclass models (allOf flattened, enums, discriminated unions decoded by their discriminator), a `Client` and an `AsyncClient` with one method per operation, auth, retries with `Retry-After` and jittered backoff, timeouts, idempotency keys, middleware hooks, pagination iterators (`_pages()` / `_items()`, `async for` variants), SSE streaming, multipart bodies, `_with_headers()` envelope variants for operations that declare response headers, and a `Servers` class for templated server URLs. -`errorMode` maps to raising `ApiError` (default) or returning a `Result` dataclass, and `dateType: Date` yields `datetime`/`date` objects. -No TypeScript is involved: generating with only `python` selected does not require the `typescript` package. +### Language SDKs + +`python`, `go`, and `php` emit a full SDK for that language — one self-contained file, no dependencies beyond the language's own HTTP support (`httpx` for Python; the standard library for Go; the curl extension for PHP). + +**They are the TypeScript client in another language.** Every capability is the same: typed models with `allOf` flattened, enums, discriminated unions decoded by their discriminator, one method per operation, auth, retries with `Retry-After` and jittered backoff, timeouts, idempotency keys, middleware, pagination iterators, SSE streaming, multipart bodies, binary downloads, typed response-header envelopes, and server-URL helpers for templated servers. +Configuration is the same too: [`serverUrl`](../commands/generate-client.md), [`dateType`](../commands/generate-client.md), [`pagination`](../configuration/reference/client.md#pagination-object), and [`codeSamples`](../configuration/reference/client.md) all apply. ```python -from client import Client +from openapi_client import Client client = Client(auth={"bearer": "TOKEN"}) for order in client.list_orders_items(limit=50): print(order) ``` -### PHP SDK - -The `php` generator emits a self-contained `.php` — a full PHP SDK over the curl extension (zero Composer dependencies, PHP ≥ 8.1): -promoted-constructor classes with `fromArray`/`toArray` hydration (allOf flattened, native backed enums, `match`-based discriminated-union dispatchers), a `Client` with one typed method per operation (optional query params as nullable named arguments), auth, retries with `Retry-After` and jittered backoff, timeouts, idempotency keys, middleware callables, pagination generators (`Pages()` / `Items()`), SSE streaming, multipart bodies, binary downloads (non-JSON success bodies return the raw `string`), `WithHeaders()` envelope variants for operations that declare response headers, and a `Servers` class for templated server URLs. -Exceptions are the error mode (`ApiError` / `TimeoutError`); `errorMode` does not change the output. -The namespace derives from the API title (for example `CafeOrdersApi`). - ```php require 'client.php'; -use CafeOrdersApi\{Client, Config}; +use CafeOrders\{Client, Config}; $client = new Client(new Config(auth: ['bearer' => 'TOKEN'])); foreach ($client->listOrdersItems(limit: 50) as $order) { @@ -89,16 +84,8 @@ foreach ($client->listOrdersItems(limit: 50) as $order) { } ``` -### Go SDK - -The `go` generator emits a self-contained `.go` — a full Go SDK over the standard library (zero dependencies, Go ≥ 1.21): -structs with `json` tags (allOf flattened, typed-const enums, discriminated-union unmarshal dispatchers), a `Client` with one `(T, error)` method per operation taking a `context.Context`, auth, retries with `Retry-After` and jittered backoff, per-attempt timeouts, idempotency keys, middleware hooks, pagination iterators (`Pages` / `Items`, `range`-over-func style), SSE streaming, multipart bodies, `WithHeaders` envelope variants (a typed headers struct) for operations that declare response headers, and `URL` helpers for templated server URLs. -Go's `(T, error)` returns are the error mode; `errorMode` does not change the output. -The iterators are `func(yield func(T, error) bool)` values: `for … range` over them needs Go ≥ 1.23; on 1.21–1.22 call them with a callback instead. - ```go api := client.New(client.Config{Auth: client.Auth{Bearer: func() string { return "TOKEN" }}}) -order, err := api.GetOrder(ctx, "ord_123") for order, err := range api.ListOrdersItems(ctx, nil) { if err != nil { @@ -108,6 +95,85 @@ for order, err := range api.ListOrdersItems(ctx, nil) { } ``` +#### Where the languages genuinely differ + +Only where the language leaves no choice: + +| Topic | TypeScript | Python | PHP | Go | +| -------------------- | ---------------------------------- | ------------------------------------------- | --------------------------------------- | -------------------------------------------- | +| Error handling | `throw` or `result` (`errorMode`) | `throw` or `result` (`errorMode`) | exceptions — the language's error idiom | `(T, error)` — the language's error idiom | +| Dates (`Date` mode) | `Date` | `datetime` / `date` | `\DateTimeImmutable` | `time.Time` / `Date` | +| Response headers | `{ envelope: true }` per call | `_with_headers()` | `WithHeaders()` | `WithHeaders` | +| Auth credentials | string or provider function | string or callable | string or callable | provider function only (no union types) | +| Reserved-word fields | not applicable | trailing `_` (`type_`), wire name preserved | trailing `_`, wire name preserved | trailing `_` (`Type_`), `json` tag preserved | +| File layout | `single` or `split` (`outputMode`) | one file | one file | one file | +| Runtime location | embedded or package (`runtime`) | embedded | embedded | embedded | + +`argsStyle` shapes TypeScript call sites; each language SDK follows its own idiom instead (keyword arguments, named arguments, a params struct). +Setting an option a language can't apply prints a warning naming the option and the reason, so it never disappears silently. + +#### Auth, middleware, and reserved names by language + +Auth accepts a static credential or a provider resolved per request: + +```python +client = Client(auth={"bearer": "TOKEN"}) +client = Client(auth={"bearer": lambda: fresh_token()}) +client = Client(auth={"apiKey": {"SecretApiKey": "KEY"}}) # "api_key" also accepted +``` + +```php +$client = new Client(new Config(auth: ['bearer' => 'TOKEN'])); +$client = new Client(new Config(auth: ['bearer' => fn () => freshToken()])); +$client = new Client(new Config(auth: ['apiKey' => ['SecretApiKey' => 'KEY']])); +``` + +```go +// Go has no union types, so a credential is always a function — even a static one. +api := client.New(client.Config{Auth: client.Auth{ + Bearer: func() string { return "TOKEN" }, + APIKey: map[string]func() string{"SecretApiKey": func() string { return "KEY" }}, +}}) +``` + +Middleware is the language's natural shape, and is **not** PSR-15/PSR-18 or an HTTPX event hook — it is this contract: + +```php +// PHP: an onion. Each callable receives the request array and the next link. +// Request keys: operationId, method, url, headers, query, and optionally body, +// contentType, idempotencyKey. The response array carries status, headers, body, +// url, timedOut. +$log = function (array $request, callable $next) use ($logger): array { + $logger->info('request', ['op' => $request['operationId'], 'url' => $request['url']]); + $response = $next($request); + $logger->info('response', ['status' => $response['status']]); + return $response; +}; +$client = new Client(new Config(middleware: [$log])); +``` + +```python +# Python: hooks. on_request sees the request context; on_response may return a +# replacement response. +import logging + +def log_request(context): + logging.info("%s %s", context["method"], context["url"]) + +client = Client(middleware=[{"on_request": log_request}]) +``` + +```go +// Go: hooks on the real *http.Request / *http.Response. +api := client.New(client.Config{Middleware: []client.Middleware{{ + OnRequest: func(r *http.Request) { log.Println(r.Method, r.URL) }, + OnResponse: func(r *http.Response) { log.Println(r.Status) }, +}}}) +``` + +A property or parameter whose name is a reserved word gets a trailing underscore, while the wire name is preserved — `tag.type_` in Python, `$tag->type_` in PHP, `tag.Type_` in Go, all serializing as `type`. +The same applies to method arguments: `list_tags(type_=...)`, `ListTagsParams{Type_: ...}`. + ## Package runtime By default the runtime is embedded in the generated file, so the client is self-contained. @@ -467,8 +533,8 @@ SSE always throws `ApiError` on a non-2xx initial response, regardless of `--err ## Pagination -Pagination is declared, never guessed: describe how your API paginates in `redocly.yaml` under `client.pagination`, or per operation with the `x-redocly-pagination` extension in the description. -The rule fields, the generate-time verification, and the precedence between the convention, `x-redocly-pagination`, and per-operation overrides are documented in the [`client.pagination` reference](../configuration/reference/client.md#pagination-object); there is no CLI flag. +Pagination is declared, never guessed: describe how your API paginates in `redocly.yaml` under `client.pagination`, or per operation with the `x-redoclyPagination` extension in the description. +The rule fields, the generate-time verification, and the precedence between the convention, `x-redoclyPagination`, and per-operation overrides are documented in the [`client.pagination` reference](../configuration/reference/client.md#pagination-object); there is no CLI flag. Each paginated operation keeps its one-shot call and gains two async iterators — `.pages(args?, init?)` yielding full pages and `.items(args?, init?)` yielding individual items, typed statically from the response schema. Four styles are supported: From 5728004c99e72ce721e2780414d089df8dfc9ab7 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 5 Aug 2026 17:35:29 +0300 Subject: [PATCH 087/211] fix(php): put the brace directly under each declaration, with no blank line --- .../src/generators/__tests__/php.test.ts | 14 ++++++++ .../src/generators/php/index.ts | 36 +++++++------------ 2 files changed, 26 insertions(+), 24 deletions(-) diff --git a/packages/client-generator/src/generators/__tests__/php.test.ts b/packages/client-generator/src/generators/__tests__/php.test.ts index bf02654271..4653ded30c 100644 --- a/packages/client-generator/src/generators/__tests__/php.test.ts +++ b/packages/client-generator/src/generators/__tests__/php.test.ts @@ -454,6 +454,20 @@ describe('phpGenerator (full client assembly)', () => { expectPhpRuns(out); }); + it('puts the brace on the line after a declaration, with no blank line between', () => { + const out = renderPhpModels( + model({ + Status: { kind: 'enum', values: ['open'], scalar: 'string' }, + Order: { kind: 'object', properties: [{ name: 'id', schema: STRING, required: true }] }, + }) + ); + expect(out).toContain('final class Order\n{'); + expect(out).toContain('enum Status: string\n{'); + expect(out).toContain('public static function fromArray(array $data): self\n {'); + expect(out).not.toMatch(/\n\n\s*\{/); + expectModelsRun(out); + }); + it('maps date/date-time to DateTimeImmutable under dateType: Date, hydrating both ways', () => { const DATE_TIME: SchemaModel = { kind: 'scalar', diff --git a/packages/client-generator/src/generators/php/index.ts b/packages/client-generator/src/generators/php/index.ts index fa92a85a8c..c1025e6ecb 100644 --- a/packages/client-generator/src/generators/php/index.ts +++ b/packages/client-generator/src/generators/php/index.ts @@ -231,7 +231,7 @@ function writeClass( ...properties.filter((property) => !property.required), ]; writeDocComment(printer, className(name), description); - printer.block(`final class ${className(name)}`, () => {}, ''); + printer.line(`final class ${className(name)}`); printer.block( '{', () => { @@ -253,7 +253,7 @@ function writeClass( printer.line('}'); printer.blank(); - printer.block('public static function fromArray(array $data): self', () => {}, ''); + printer.line('public static function fromArray(array $data): self'); printer.block( '{', () => { @@ -280,7 +280,7 @@ function writeClass( ); printer.blank(); - printer.block('public function toArray(): array', () => {}, ''); + printer.line('public function toArray(): array'); printer.block( '{', () => { @@ -318,7 +318,7 @@ export function renderPhpModels(model: ApiModel, dateType: DateType = 'string'): if (asEnum !== undefined && (asEnum.scalar === 'string' || asEnum.scalar === 'integer')) { const backing = asEnum.scalar === 'string' ? 'string' : 'int'; writeDocComment(printer, className(name), schema.description); - printer.block(`enum ${className(name)}: ${backing}`, () => {}, ''); + printer.line(`enum ${className(name)}: ${backing}`); printer.block( '{', () => { @@ -356,7 +356,7 @@ export function renderPhpModels(model: ApiModel, dateType: DateType = 'string'): printer.line( `/** ${typeName} is a discriminated union (${phpString(cases.property)}): ${table}. */` ); - printer.block(`function unmarshal${typeName}(array $data): mixed`, () => {}, ''); + printer.line(`function unmarshal${typeName}(array $data): mixed`); printer.block( '{', () => { @@ -571,11 +571,7 @@ function writePhpMethod( ? `Like ${methodName(op)}(), returning an Envelope with the declared response headers.` : (op.summary ?? `${op.method.toUpperCase()} ${op.path}`) ); - printer.block( - `public function ${name}(${args.signature.join(', ')}): ${returnType}`, - () => {}, - '' - ); + printer.line(`public function ${name}(${args.signature.join(', ')}): ${returnType}`); printer.block( '{', () => { @@ -719,11 +715,7 @@ function writePhpPaginationWrappers( }; printer.line(`/** ${name} response pages, following the pagination rule automatically. */`); - printer.block( - `public function ${name}Pages(${args.signature.join(', ')}): \\Generator`, - () => {}, - '' - ); + printer.line(`public function ${name}Pages(${args.signature.join(', ')}): \\Generator`); printer.block( '{', () => { @@ -741,11 +733,7 @@ function writePhpPaginationWrappers( printer.blank(); printer.line(`/** The items of every ${name} page. */`); - printer.block( - `public function ${name}Items(${args.signature.join(', ')}): \\Generator`, - () => {}, - '' - ); + printer.line(`public function ${name}Items(${args.signature.join(', ')}): \\Generator`); printer.block( '{', () => { @@ -802,7 +790,7 @@ function writeServers(printer: Printer, model: ApiModel): void { printer.line( '/** The declared servers; variables default to the values from the description. */' ); - printer.block('final class Servers', () => {}, ''); + printer.line('final class Servers'); printer.block( '{', () => { @@ -818,7 +806,7 @@ function writeServers(printer: Printer, model: ApiModel): void { `string ${'$'}${propertyName(variable.name)} = ${phpString(variable.default)}` ); if (index > 0) printer.blank(); - printer.block(`public static function ${name}(${params.join(', ')}): string`, () => {}, ''); + printer.line(`public static function ${name}(${params.join(', ')}): string`); printer.block( '{', () => { @@ -901,11 +889,11 @@ export const phpGenerator: Generator = ({ model, outputPath, emit }) => { writeDocComment(printer, 'Client', `Client for ${model.title} (${model.version}).`); // Not final: PHP test suites mock concrete classes (createMock(Client::class)). - printer.block('class Client', () => {}, ''); + printer.line('class Client'); printer.block( '{', () => { - printer.block('public function __construct(private Config $config)', () => {}, ''); + printer.line('public function __construct(private Config $config)'); printer.block( '{', () => { From 33e24b6074528f02ad8955fa3a6d768ba3eb9e92 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 5 Aug 2026 17:44:53 +0300 Subject: [PATCH 088/211] feat(php): document element types in PHPDoc where the signature erases them --- .../src/generators/__tests__/php.test.ts | 57 ++++++++++++++++ .../src/generators/php/AGENTS.md | 4 ++ .../src/generators/php/index.ts | 68 ++++++++++++++++--- .../generators/php.AGENTS.md | 4 ++ 4 files changed, 125 insertions(+), 8 deletions(-) diff --git a/packages/client-generator/src/generators/__tests__/php.test.ts b/packages/client-generator/src/generators/__tests__/php.test.ts index 4653ded30c..b9f4c1ff23 100644 --- a/packages/client-generator/src/generators/__tests__/php.test.ts +++ b/packages/client-generator/src/generators/__tests__/php.test.ts @@ -442,6 +442,63 @@ describe('phpGenerator (full client assembly)', () => { expect(out).toContain("return $response['body'];"); }); + it('documents element types PHP cannot express in the signature', () => { + // A bare-array collection: `array` in the signature, element type in the docblock. + const collection: ApiModel = { + title: 'Cafe', + version: '1.0.0', + serverUrl: 'https://api.cafe.example', + services: [ + { + name: 'Orders', + operations: [ + { + name: 'listOrders', + specName: 'listOrders', + method: 'get', + path: '/orders', + tags: ['Orders'], + pathParams: [], + queryParams: [{ name: 'cursor', in: 'query', required: false, schema: STRING }], + headerParams: [], + cookieParams: [], + security: [], + paginationExtension: { style: 'cursor', cursorParam: 'cursor', items: '' }, + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'array', items: { kind: 'ref', name: 'Order' } }, + }, + ], + errorResponses: [], + }, + ], + }, + ], + schemas: [ + { + name: 'Order', + schema: { kind: 'object', properties: [{ name: 'id', schema: STRING, required: true }] }, + }, + ], + securitySchemes: [], + } as unknown as ApiModel; + + const out = phpGenerator({ + model: collection, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: {}, + })[0].content; + + expect(out).toContain('@return Order[]'); + // Iterators say what they yield, so static analysis can follow them. + expect(out).toContain('@return \\Generator'); + expect(out).toContain('@return \\Generator'); + expectPhpRuns(out); + }); + it('emits a WithHeaders envelope variant only for ops with declared response headers', () => { const out = generatePhp(); expect(out).toContain('public function listOrdersWithHeaders('); diff --git a/packages/client-generator/src/generators/php/AGENTS.md b/packages/client-generator/src/generators/php/AGENTS.md index 47f1ef3e58..2f4dd0f393 100644 --- a/packages/client-generator/src/generators/php/AGENTS.md +++ b/packages/client-generator/src/generators/php/AGENTS.md @@ -41,6 +41,10 @@ extension — zero Composer dependencies. The namespace derives from the API tit $idempotencyKey` on mutating methods. - **Non-JSON success bodies** (PDFs, images, octet streams) return the raw body as `string` — a binary download must never degrade to `void`. +- **PHPDoc carries what the signature cannot.** PHP's `array` and `\Generator` erase their + element type, so a docblock states it: `@return Customer[]` for collection returns and + `@return \Generator` on `Pages()`/`Items()`. Static analysis and + readers go by these; a hydrated return with no annotation looks untyped. - **Response headers:** an operation that DECLARES success-response headers gains a `WithHeaders()` variant returning an `Envelope` (`data`, `headers` — coerced to int/bool/string with camelCase keys, absent/unparsable values omitted — and `status`). diff --git a/packages/client-generator/src/generators/php/index.ts b/packages/client-generator/src/generators/php/index.ts index c1025e6ecb..b0ed27d7e8 100644 --- a/packages/client-generator/src/generators/php/index.ts +++ b/packages/client-generator/src/generators/php/index.ts @@ -211,10 +211,48 @@ function serialization( return undefined; } -function writeDocComment(printer: Printer, name: string, description?: string): void { +function writeDocComment( + printer: Printer, + name: string, + description?: string, + tags: string[] = [] +): void { const lines = docText(description); - if (lines.length === 0) return; - printer.line(`/** ${name} — ${lines.join(' ')} */`); + if (lines.length === 0 && tags.length === 0) return; + const summary = lines.length === 0 ? name : `${name} — ${lines.join(' ')}`; + if (tags.length === 0) { + printer.line(`/** ${summary} */`); + return; + } + printer.line('/**'); + printer.line(` * ${summary}`); + printer.line(' *'); + for (const tag of tags) printer.line(` * ${tag}`); + printer.line(' */'); +} + +/** + * The element type behind a PHP type that erases it. `array` and `\Generator` are as + * specific as PHP's syntax gets, so the docblock carries what they hold — that is what + * static analysis and readers actually go by. + */ +function phpElementType( + schema: SchemaModel | undefined, + model: ApiModel, + dateType: DateType +): string | undefined { + if (schema === undefined) return undefined; + const bare = unwrapNullable(schema); + if (bare.kind === 'ref') { + const target = deref(bare, model); + // A named schema that IS an array (a collection alias) keeps its element type. + return classify(bare.name, model) === 'other' + ? phpElementType(target, model, dateType) + : undefined; + } + if (bare.kind !== 'array') return undefined; + const element = phpType(bare.items, model, dateType); + return element === 'mixed' ? undefined : element; } function writeClass( @@ -564,12 +602,14 @@ function writePhpMethod( ? 'string' : 'void'; const name = envelope ? `${methodName(op)}WithHeaders` : methodName(op); + const element = envelope ? undefined : phpElementType(success, model, dateType); writeDocComment( printer, name, envelope ? `Like ${methodName(op)}(), returning an Envelope with the declared response headers.` - : (op.summary ?? `${op.method.toUpperCase()} ${op.path}`) + : (op.summary ?? `${op.method.toUpperCase()} ${op.path}`), + element === undefined ? [] : [`@return ${element}[]`] ); printer.line(`public function ${name}(${args.signature.join(', ')}): ${returnType}`); printer.block( @@ -663,7 +703,8 @@ function writePhpPaginationWrappers( dateType: DateType, pageHydration: string | undefined, itemHydration: string | undefined, - itemsPointer: string | undefined + itemsPointer: string | undefined, + itemYield: string ): void { const args = methodArgs(op, model, false, dateType); const name = methodName(op); @@ -714,7 +755,13 @@ function writePhpPaginationWrappers( ); }; - printer.line(`/** ${name} response pages, following the pagination rule automatically. */`); + const pageType = phpType(successSchema(op) ?? { kind: 'unknown' }, model, dateType); + const pageYield = pageType === 'mixed' ? 'mixed' : pageType; + printer.line('/**'); + printer.line(` * ${name} response pages, following the pagination rule automatically.`); + printer.line(' *'); + printer.line(` * @return \\Generator`); + printer.line(' */'); printer.line(`public function ${name}Pages(${args.signature.join(', ')}): \\Generator`); printer.block( '{', @@ -732,7 +779,11 @@ function writePhpPaginationWrappers( ); printer.blank(); - printer.line(`/** The items of every ${name} page. */`); + 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( '{', @@ -937,7 +988,8 @@ export const phpGenerator: Generator = ({ model, outputPath, emit }) => { dateType, pageHydration, itemHydration, - rule.items + rule.items, + element === undefined ? 'mixed' : phpType(element, model, dateType) ); } }, diff --git a/tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md b/tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md index d5cc1dd4cc..5c8b813304 100644 --- a/tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md +++ b/tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md @@ -40,6 +40,10 @@ extension — zero Composer dependencies. The namespace derives from the API tit $idempotencyKey` on mutating methods. - **Non-JSON success bodies** (PDFs, images, octet streams) return the raw body as `string` — a binary download must never degrade to `void`. +- **PHPDoc carries what the signature cannot.** PHP's `array` and `\Generator` erase their + element type, so a docblock states it: `@return Customer[]` for collection returns and + `@return \Generator` on `Pages()`/`Items()`. Static analysis and + readers go by these; a hydrated return with no annotation looks untyped. - **Response headers:** an operation that DECLARES success-response headers gains a `WithHeaders()` variant returning an `Envelope` (`data`, `headers` — coerced to int/bool/string with camelCase keys, absent/unparsable values omitted — and `status`). From c96f2b52be4eb7fad1390d0d4b75a70322c00add Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 5 Aug 2026 17:50:02 +0300 Subject: [PATCH 089/211] fix(python): emit an importable module name for the generated client --- .../src/generators/__tests__/python.test.ts | 15 +++++++++++++++ .../src/generators/python/AGENTS.md | 6 ++++++ .../src/generators/python/index.ts | 16 +++++++++++++++- 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/client-generator/src/generators/__tests__/python.test.ts b/packages/client-generator/src/generators/__tests__/python.test.ts index 83ccd1e33e..4846e37811 100644 --- a/packages/client-generator/src/generators/__tests__/python.test.ts +++ b/packages/client-generator/src/generators/__tests__/python.test.ts @@ -346,6 +346,21 @@ function generate(errorMode: 'throw' | 'result' = 'throw'): string { return files[0].content; } +describe('python output path', () => { + const pathFor = (outputPath: string) => + pythonGenerator({ model: CAFE, outputPath, outputMode: 'single', emit: {} })[0].path; + + it('emits an importable module name — the TypeScript stem is not one', () => { + // `openapi.client.py` and `rebilly-core.client.py` cannot be imported by name. + expect(pathFor('/out/openapi.client.ts')).toBe('/out/openapi_client.py'); + expect(pathFor('/out/rebilly-core.client.ts')).toBe('/out/rebilly_core_client.py'); + // A stem that is already importable is left alone. + expect(pathFor('/out/client.ts')).toBe('/out/client.py'); + // A leading digit would be a syntax error in an import. + expect(pathFor('/out/3rd-party.client.ts')).toBe('/out/_3rd_party_client.py'); + }); +}); + describe('pythonGenerator (full client assembly)', () => { it('renders typed sync methods — kwargs for query params, positional path params, hydrated returns', () => { const out = generate(); diff --git a/packages/client-generator/src/generators/python/AGENTS.md b/packages/client-generator/src/generators/python/AGENTS.md index 7765d29970..07020068ce 100644 --- a/packages/client-generator/src/generators/python/AGENTS.md +++ b/packages/client-generator/src/generators/python/AGENTS.md @@ -13,6 +13,12 @@ One self-contained `.py`: typed dataclass models, a sync `Client` and an a ## Design decisions that must hold +- **The file name is an importable module name.** The `--output` stem follows the TypeScript + convention (`openapi.client.ts`), and `openapi.client.py` cannot be imported by name — nor + can hyphens or a leading digit. The stem is converted with + `identifierFor(stem, snake)`, so `rebilly-core.client.ts` emits + `rebilly_core_client.py` and `import rebilly_core_client` just works. + - **Models are dataclasses**, required fields first (a dataclass constraint), optionals `Optional[T] = None`. Wire names live in a `_field_map: ClassVar[Dict[str, str]]`; decode/encode is reflective (`_decode.py`, `get_type_hints`) — no per-model codecs. diff --git a/packages/client-generator/src/generators/python/index.ts b/packages/client-generator/src/generators/python/index.ts index 3cb0c2e11c..affa9a6e1a 100644 --- a/packages/client-generator/src/generators/python/index.ts +++ b/packages/client-generator/src/generators/python/index.ts @@ -650,6 +650,20 @@ function writeClientClass( printer.blank(); } +/** + * The output path with an IMPORTABLE module name. The `--output` stem follows the + * TypeScript convention (`openapi.client.ts`), and `openapi.client.py` cannot be + * imported by name — nor can a hyphen or a leading digit — so the stem is converted + * to a legal module identifier (`openapi_client.py`). The directory is untouched. + */ +function pythonModulePath(outputPath: string): string { + const separator = outputPath.lastIndexOf('/') >= 0 ? '/' : '\\'; + const cut = outputPath.lastIndexOf(separator); + const dir = cut >= 0 ? outputPath.slice(0, cut + 1) : ''; + const stem = (cut >= 0 ? outputPath.slice(cut + 1) : outputPath).replace(/\.[^.]+$/, ''); + return `${dir}${identifierFor(stem, { style: 'snake', reserved: PY })}.py`; +} + /** The whole generated file: header, models, embedded runtime, descriptors, clients. */ export const pythonGenerator: Generator = ({ model, outputPath, emit }) => { const errorMode = emit.errorMode ?? 'throw'; @@ -730,7 +744,7 @@ export const pythonGenerator: Generator = ({ model, outputPath, emit }) => { writeClientClass(printer, model, errorMode, false, paginationSpecs, serverUrl, dateType); writeClientClass(printer, model, errorMode, true, paginationSpecs, serverUrl, dateType); - return [{ path: outputPath.replace(/\.[^.\\/]+$/, '.py'), content: printer.toString() }]; + return [{ path: pythonModulePath(outputPath), content: printer.toString() }]; }; /** One idiomatic Python call per operation — feeds `x-codeSamples` for docs. */ From 63d369ab1f1c6830ab272fee0dc01e9999d0d21d Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 5 Aug 2026 17:55:27 +0300 Subject: [PATCH 090/211] fix(python): accept the cross-language apiKey auth key, not only api_key --- .../client-generator/python-runtime/_auth.py | 10 ++++-- .../src/emitters/python-runtime-sources.ts | 2 +- .../src/generators/__tests__/python.test.ts | 31 +++++++++++++++++++ .../src/generators/python/AGENTS.md | 3 ++ 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/packages/client-generator/python-runtime/_auth.py b/packages/client-generator/python-runtime/_auth.py index cb21fcd00e..c6bed0347e 100644 --- a/packages/client-generator/python-runtime/_auth.py +++ b/packages/client-generator/python-runtime/_auth.py @@ -11,6 +11,12 @@ TokenProvider = Union[str, Callable[[], str]] +def _api_keys(auth: Dict[str, Any]) -> Dict[str, Any]: + """The apiKey credentials. `apiKey` is the documented key (it matches the scheme + kind and the other language SDKs); `api_key` is accepted too, so a snake_case + config keeps working.""" + return {**(auth.get("api_key") or {}), **(auth.get("apiKey") or {})} + def _resolve_token(provider: TokenProvider) -> str: return provider() if callable(provider) else provider @@ -18,7 +24,7 @@ def _resolve_token(provider: TokenProvider) -> str: def _is_configured(scheme: Dict[str, Any], auth: Dict[str, Any]) -> bool: kind = scheme["kind"] if kind == "apiKey": - return scheme["scheme"] in (auth.get("api_key") or {}) + return scheme["scheme"] in _api_keys(auth) if kind == "bearer": return auth.get("bearer") is not None return auth.get("basic") is not None @@ -41,7 +47,7 @@ def resolve_auth( for scheme in alternative: kind = scheme["kind"] if kind == "apiKey": - provider = (auth.get("api_key") or {}).get(scheme["scheme"]) + provider = _api_keys(auth).get(scheme["scheme"]) if provider is None: continue value = _resolve_token(provider) diff --git a/packages/client-generator/src/emitters/python-runtime-sources.ts b/packages/client-generator/src/emitters/python-runtime-sources.ts index 2f92525479..0ae70156cb 100644 --- a/packages/client-generator/src/emitters/python-runtime-sources.ts +++ b/packages/client-generator/src/emitters/python-runtime-sources.ts @@ -3,7 +3,7 @@ export const PYTHON_RUNTIME_SOURCES = { '_errors.py': '# Runtime errors and the result-mode envelope for generated Python clients.\n# Hand-authored once, embedded into every generated client (see\n# scripts/generate-runtime-sources.mjs) — mirror of the TypeScript runtime\'s\n# errors.ts, kept semantically in lockstep.\nfrom __future__ import annotations\n\nfrom dataclasses import dataclass\nfrom typing import Any, Generic, Optional, TypeVar\n\nT = TypeVar("T")\nE = TypeVar("E")\n\n\nclass ApiError(Exception):\n """Raised (throw mode) for a non-2xx response, carrying the decoded error body."""\n\n def __init__(self, url: str, status: int, status_text: str, body: Any) -> None:\n super().__init__(f"Request failed with status {status}")\n self.url = url\n self.status = status\n self.status_text = status_text\n self.body = body\n\n\nclass ApiTimeoutError(Exception):\n """Raised when a request attempt exceeds the configured timeout — carries the\n context a log line needs (which operation, what budget, which attempt)."""\n\n def __init__(self, operation_id: str, timeout: float, attempt: int) -> None:\n super().__init__(\n f\'Request "{operation_id}" timed out after {timeout} s (attempt {attempt})\'\n )\n self.operation_id = operation_id\n self.timeout = timeout\n self.attempt = attempt\n\n\n@dataclass\nclass Result(Generic[T, E]):\n """Result-mode return shape: exactly one of `data`/`error` is set."""\n\n data: Optional[T]\n error: Optional[E]\n response: Any # httpx.Response\n\n @property\n def ok(self) -> bool:\n return self.error is None\n', '_auth.py': - '# Auth resolution for generated Python clients — mirror of the TypeScript\n# runtime\'s auth.ts: the first OR-alternative whose schemes are all configured\n# is applied, so "bearer OR apiKey" works with either credential and never\n# sends both. Cookie-borne api keys fold into a single Cookie header.\nfrom __future__ import annotations\n\nimport base64\nfrom typing import Any, Callable, Dict, List, Tuple, Union\nfrom urllib.parse import quote\n\nTokenProvider = Union[str, Callable[[], str]]\n\n\ndef _resolve_token(provider: TokenProvider) -> str:\n return provider() if callable(provider) else provider\n\n\ndef _is_configured(scheme: Dict[str, Any], auth: Dict[str, Any]) -> bool:\n kind = scheme["kind"]\n if kind == "apiKey":\n return scheme["scheme"] in (auth.get("api_key") or {})\n if kind == "bearer":\n return auth.get("bearer") is not None\n return auth.get("basic") is not None\n\n\ndef resolve_auth(\n security: List[List[Dict[str, Any]]], auth: Dict[str, Any]\n) -> Tuple[Dict[str, str], Dict[str, str]]:\n """Build (headers, query) for one operation\'s security OR-alternatives from\n the client credentials. When no alternative is fully configured, the first\n alternative\'s configured schemes are still sent (the server rejects the\n request — same behavior as the TypeScript runtime)."""\n alternative = next(\n (schemes for schemes in security if all(_is_configured(s, auth) for s in schemes)),\n security[0] if security else [],\n )\n headers: Dict[str, str] = {}\n query: Dict[str, str] = {}\n cookies: List[str] = []\n for scheme in alternative:\n kind = scheme["kind"]\n if kind == "apiKey":\n provider = (auth.get("api_key") or {}).get(scheme["scheme"])\n if provider is None:\n continue\n value = _resolve_token(provider)\n location = scheme.get("in", "header")\n if location == "header":\n headers[scheme["name"]] = value\n elif location == "query":\n query[scheme["name"]] = value\n else:\n # Reserved characters (`;`, `=`, space) must not break Cookie syntax.\n cookies.append(f"{scheme[\'name\']}={quote(value, safe=\'\')}")\n elif kind == "bearer":\n provider = auth.get("bearer")\n if provider is not None:\n headers["Authorization"] = f"Bearer {_resolve_token(provider)}"\n else:\n basic = auth.get("basic")\n if basic is not None:\n username, password = basic["username"], basic["password"]\n token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii")\n headers["Authorization"] = f"Basic {token}"\n if cookies:\n headers["Cookie"] = "; ".join(cookies)\n return headers, query\n', + '# Auth resolution for generated Python clients — mirror of the TypeScript\n# runtime\'s auth.ts: the first OR-alternative whose schemes are all configured\n# is applied, so "bearer OR apiKey" works with either credential and never\n# sends both. Cookie-borne api keys fold into a single Cookie header.\nfrom __future__ import annotations\n\nimport base64\nfrom typing import Any, Callable, Dict, List, Tuple, Union\nfrom urllib.parse import quote\n\nTokenProvider = Union[str, Callable[[], str]]\n\n\ndef _api_keys(auth: Dict[str, Any]) -> Dict[str, Any]:\n """The apiKey credentials. `apiKey` is the documented key (it matches the scheme\n kind and the other language SDKs); `api_key` is accepted too, so a snake_case\n config keeps working."""\n return {**(auth.get("api_key") or {}), **(auth.get("apiKey") or {})}\n\ndef _resolve_token(provider: TokenProvider) -> str:\n return provider() if callable(provider) else provider\n\n\ndef _is_configured(scheme: Dict[str, Any], auth: Dict[str, Any]) -> bool:\n kind = scheme["kind"]\n if kind == "apiKey":\n return scheme["scheme"] in _api_keys(auth)\n if kind == "bearer":\n return auth.get("bearer") is not None\n return auth.get("basic") is not None\n\n\ndef resolve_auth(\n security: List[List[Dict[str, Any]]], auth: Dict[str, Any]\n) -> Tuple[Dict[str, str], Dict[str, str]]:\n """Build (headers, query) for one operation\'s security OR-alternatives from\n the client credentials. When no alternative is fully configured, the first\n alternative\'s configured schemes are still sent (the server rejects the\n request — same behavior as the TypeScript runtime)."""\n alternative = next(\n (schemes for schemes in security if all(_is_configured(s, auth) for s in schemes)),\n security[0] if security else [],\n )\n headers: Dict[str, str] = {}\n query: Dict[str, str] = {}\n cookies: List[str] = []\n for scheme in alternative:\n kind = scheme["kind"]\n if kind == "apiKey":\n provider = _api_keys(auth).get(scheme["scheme"])\n if provider is None:\n continue\n value = _resolve_token(provider)\n location = scheme.get("in", "header")\n if location == "header":\n headers[scheme["name"]] = value\n elif location == "query":\n query[scheme["name"]] = value\n else:\n # Reserved characters (`;`, `=`, space) must not break Cookie syntax.\n cookies.append(f"{scheme[\'name\']}={quote(value, safe=\'\')}")\n elif kind == "bearer":\n provider = auth.get("bearer")\n if provider is not None:\n headers["Authorization"] = f"Bearer {_resolve_token(provider)}"\n else:\n basic = auth.get("basic")\n if basic is not None:\n username, password = basic["username"], basic["password"]\n token = base64.b64encode(f"{username}:{password}".encode("utf-8")).decode("ascii")\n headers["Authorization"] = f"Basic {token}"\n if cookies:\n headers["Cookie"] = "; ".join(cookies)\n return headers, query\n', '_url.py': '# URL assembly for generated Python clients — path-parameter substitution with\n# percent-encoding, mirroring the TypeScript runtime\'s url.ts semantics.\nfrom __future__ import annotations\n\nfrom typing import Any, Dict\nfrom urllib.parse import quote\n\n\ndef build_url(server_url: str, path: str, path_params: Dict[str, Any]) -> str:\n filled = path\n for name, value in path_params.items():\n filled = filled.replace("{" + name + "}", quote(str(value), safe=""))\n return server_url.rstrip("/") + filled\n', '_decode.py': diff --git a/packages/client-generator/src/generators/__tests__/python.test.ts b/packages/client-generator/src/generators/__tests__/python.test.ts index 4846e37811..7d42712cd7 100644 --- a/packages/client-generator/src/generators/__tests__/python.test.ts +++ b/packages/client-generator/src/generators/__tests__/python.test.ts @@ -346,6 +346,37 @@ function generate(errorMode: 'throw' | 'result' = 'throw'): string { return files[0].content; } +describe('python auth keys', () => { + it('accepts apiKey (the documented, cross-language key) and api_key alike', () => { + if (!hasHttpx) return; + const out = pythonGenerator({ + model: CAFE, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: {}, + })[0].content; + const dir = mkdtempSync(join(tmpdir(), 'py-auth-')); + try { + writeFileSync(join(dir, 'client.py'), out); + const run = spawnSync( + 'python3', + [ + '-c', + 'import client;' + + ' spec = [[{"kind": "apiKey", "scheme": "K", "name": "X-Key", "in": "header"}]];' + + ' print(client.resolve_auth(spec, {"apiKey": {"K": "v"}})[0]);' + + ' print(client.resolve_auth(spec, {"api_key": {"K": "v"}})[0])', + ], + { cwd: dir, encoding: 'utf-8' } + ); + expect(run.status, run.stderr).toBe(0); + expect(run.stdout.trim().split('\n')).toEqual(["{'X-Key': 'v'}", "{'X-Key': 'v'}"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + describe('python output path', () => { const pathFor = (outputPath: string) => pythonGenerator({ model: CAFE, outputPath, outputMode: 'single', emit: {} })[0].path; diff --git a/packages/client-generator/src/generators/python/AGENTS.md b/packages/client-generator/src/generators/python/AGENTS.md index 07020068ce..45ce1abed5 100644 --- a/packages/client-generator/src/generators/python/AGENTS.md +++ b/packages/client-generator/src/generators/python/AGENTS.md @@ -30,6 +30,9 @@ One self-contained `.py`: typed dataclass models, a sync `Client` and an a and `decode()` routes through it — `isinstance` narrowing works on decoded members. Undiscriminated unions decode by trying each member in order (the first that hydrates wins — see `_decode.py`). **allOf** is flattened via `flattenAllOf`. +- **Auth keys match the other languages.** `auth={"apiKey": {...}}` is the documented key — + the same spelling TypeScript and PHP use, and the same as the scheme kind — with + `api_key` accepted as an alias so a snake_case config keeps working. - **Errors:** `errorMode` maps to raising `ApiError` (default) or returning a `Result` dataclass — the only generator with both modes outside TypeScript. - **Dates:** `dateType: Date` annotates `format: date-time` as `datetime` and `date` as From a916968b0bfae6e111286fb3f3e348654992934c Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 5 Aug 2026 18:12:15 +0300 Subject: [PATCH 091/211] =?UTF-8?q?fix(go):=20emit=20gofmt-clean=20output?= =?UTF-8?q?=20=E2=80=94=20column=20alignment,=20switch=20indentation,=20co?= =?UTF-8?q?mment=20whitespace?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/generators/__tests__/go.test.ts | 58 ++++++--- .../src/generators/go/AGENTS.md | 13 ++ .../src/generators/go/index.ts | 116 +++++++++++++++--- 3 files changed, 155 insertions(+), 32 deletions(-) diff --git a/packages/client-generator/src/generators/__tests__/go.test.ts b/packages/client-generator/src/generators/__tests__/go.test.ts index be3296e9d7..981c2fa8f3 100644 --- a/packages/client-generator/src/generators/__tests__/go.test.ts +++ b/packages/client-generator/src/generators/__tests__/go.test.ts @@ -12,6 +12,25 @@ const hasGo = spawnSync('go', ['version']).status === 0; // CI cache compiles the stdlib and takes well over the 5s default. vi.setConfig({ testTimeout: 180_000 }); +/** Assert `gofmt` would not change the source — the output must ship idiomatic. */ +function expectGofmtClean(source: string): void { + if (!hasGo) return; + const dir = mkdtempSync(join(tmpdir(), 'go-fmt-')); + try { + const file = join(dir, 'client.go'); + writeFileSync(file, source); + const listed = spawnSync('gofmt', ['-l', file], { encoding: 'utf-8' }); + expect(listed.status, listed.stderr).toBe(0); + const diff = + listed.stdout.trim() === '' + ? '' + : spawnSync('gofmt', ['-d', file], { encoding: 'utf-8' }).stdout; + expect(listed.stdout.trim(), `gofmt would reformat the output:\n${diff}`).toBe(''); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + /** Assert the rendered source is compilable Go (skipped without the toolchain). */ function expectGoCompiles(source: string): void { if (!hasGo) return; @@ -56,9 +75,9 @@ describe('renderGoModels', () => { ); expect(out).toContain('// Order — One placed order.'); expect(out).toContain('type Order struct {'); - expect(out).toContain('Id string `json:"id"`'); - expect(out).toContain('Quantity int64 `json:"quantity"`'); - expect(out).toContain('Note *string `json:"note,omitempty"`'); + expect(out).toMatch(/Id\s+string\s+`json:"id"`/); + expect(out).toMatch(/Quantity\s+int64\s+`json:"quantity"`/); + expect(out).toMatch(/Note\s+\*string\s+`json:"note,omitempty"`/); expectGoCompiles(out); }); @@ -82,7 +101,7 @@ describe('renderGoModels', () => { }) ); expect(out).toContain('type Page struct {'); - expect(out).toContain('Items []string `json:"items"`'); + expect(out).toMatch(/Items\s+\[\]string\s+`json:"items"`/); // The exported field name is always usable; the tag keeps the exact wire name. expect(out).toContain('`json:"go"`'); expectGoCompiles(out); @@ -127,7 +146,7 @@ describe('renderGoModels', () => { }, }) ); - expect(out).toContain('N3ds *string `json:"3ds,omitempty"`'); + expect(out).toMatch(/N3ds\s+\*string\s+`json:"3ds,omitempty"`/); expectGoCompiles(out); }); @@ -143,8 +162,8 @@ describe('renderGoModels', () => { }, }) ); - expect(out).toContain('Plus1 int64 `json:"+1"`'); - expect(out).toContain('Minus1 int64 `json:"-1"`'); + expect(out).toMatch(/Plus1\s+int64\s+`json:"\+1"`/); + expect(out).toMatch(/Minus1\s+int64\s+`json:"-1"`/); expectGoCompiles(out); }); @@ -164,8 +183,8 @@ describe('renderGoModels', () => { }, }) ); - expect(out).toContain('Tag *string `json:"tag"`'); - expect(out).toContain('Meta map[string]string `json:"meta"`'); + expect(out).toMatch(/Tag\s+\*string\s+`json:"tag"`/); + expect(out).toMatch(/Meta\s+map\[string\]string\s+`json:"meta"`/); expectGoCompiles(out); }); }); @@ -365,7 +384,7 @@ describe('goGenerator (full client assembly)', () => { it('assembles one compilable file: models + embedded runtime + operations table', () => { const out = generateGo(); expect(out).toContain('var operations = map[string]operationMeta{'); - expect(out).toContain('"listOrders": {'); + expect(out).toMatch(/"listOrders":\s+\{/); expect(out).toContain('func send(ctx context.Context'); // embedded runtime expect((out.match(/^package client$/gm) ?? []).length).toBe(1); expectGoCompiles(out); @@ -395,11 +414,18 @@ describe('goGenerator parity features', () => { expectGoCompiles(out); }); + it('emits gofmt-clean output — aligned struct fields and const blocks', () => { + const out = generateGo(); + // The alignment gofmt would apply, applied by us. + expect(out).toMatch(/Id\s+string\s+`json:"id"`/); + expectGofmtClean(out); + }); + it('emits a WithHeaders envelope variant only for ops with declared response headers', () => { const out = generateGo(); expect(out).toContain('type ListOrdersHeaders struct {'); - expect(out).toContain('PaginationTotal *int64'); - expect(out).toContain('Link *string'); + expect(out).toMatch(/PaginationTotal\s+\*int64/); + expect(out).toMatch(/Link\s+\*string/); expect(out).toContain( 'func (c *Client) ListOrdersWithHeaders(ctx context.Context, params *ListOrdersParams) (OrderPage, ListOrdersHeaders, error) {' ); @@ -471,10 +497,10 @@ describe('goGenerator parity features', () => { emit: { dateType: 'Date' }, })[0].content; - expect(out).toContain('PlacedAt time.Time `json:"placedAt"`'); + expect(out).toMatch(/PlacedAt\s+time\.Time\s+`json:"placedAt"`/); // A calendar date needs its own type: encoding/json only speaks RFC 3339 for time.Time. - expect(out).toContain('DueDate *Date `json:"dueDate,omitempty"`'); - expect(out).toContain('Reminders []time.Time `json:"reminders,omitempty"`'); + expect(out).toMatch(/DueDate\s+\*Date\s+`json:"dueDate,omitempty"`/); + expect(out).toMatch(/Reminders\s+\[\]time\.Time\s+`json:"reminders,omitempty"`/); expect(out).toContain('Since *time.Time'); expect(out).toContain('query.Set("since", (*params.Since).Format(time.RFC3339))'); expectGoCompiles(out); @@ -486,7 +512,7 @@ describe('goGenerator parity features', () => { outputMode: 'single', emit: {}, })[0].content; - expect(asString).toContain('PlacedAt string `json:"placedAt"`'); + expect(asString).toMatch(/PlacedAt\s+string\s+`json:"placedAt"`/); }); it('models referencing dates compile standalone (the models section imports time)', () => { diff --git a/packages/client-generator/src/generators/go/AGENTS.md b/packages/client-generator/src/generators/go/AGENTS.md index c9445af792..6922a8458d 100644 --- a/packages/client-generator/src/generators/go/AGENTS.md +++ b/packages/client-generator/src/generators/go/AGENTS.md @@ -41,6 +41,19 @@ runtime. Go ≥ 1.21, standard library only — zero dependencies. `context.WithTimeout`, idempotency keys, middleware, pagination (`Pages`/`Items` as `func(yield func(T, error) bool)` — `range`-over-func needs Go ≥ 1.23; 1.21 calls them with a callback), SSE, multipart. +- **The EMITTED FILE is gofmt-clean, not just the runtime.** `gofmt -l` on generated + output must print nothing, so the download is idiomatic as-is. The emitter earns that + deterministically, without shelling out to `gofmt`: + - `alignGoColumns` pads columns the way gofmt's tabwriter does — struct field types and + tags, `const`/`var` types and `=`, and map-literal values — within each contiguous run. + A line starting with a Go KEYWORD is a statement, never a declaration, and must never + be padded (`case "x":` is not a field). + - `case` sits at its `switch`'s own indent, so the switch body is not emitted as an + indented block. + - At most one blank line between declarations, none at end of file, and a blank line + inside a doc comment is `//` — never `// ` with a trailing space. + A change here is verified by the `gofmt -l` bar in the unit suite, at cafe AND + large-description scale. - The runtime is hand-written in `go-runtime/runtime.go` (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/src/generators/go/index.ts b/packages/client-generator/src/generators/go/index.ts index 50aee26cea..a53b3bd17a 100644 --- a/packages/client-generator/src/generators/go/index.ts +++ b/packages/client-generator/src/generators/go/index.ts @@ -92,7 +92,8 @@ function writeDocComment(printer: Printer, name: string, description?: string): const lines = docText(description); if (lines.length === 0) return; printer.line(`// ${name} — ${lines[0]}`); - for (const line of lines.slice(1)) printer.line(`// ${line}`); + // A blank line inside a description is `//`, never `// ` — gofmt strips the space. + for (const line of lines.slice(1)) printer.line(line === '' ? '//' : `// ${line}`); } function writeStruct( @@ -149,7 +150,7 @@ export function renderGoModels(model: ApiModel, dateType: DateType = 'string'): printer.blank(); } printer.line(body); - return printer.toString(); + return alignGoColumns(printer.toString()); } /** The struct/enum/union declarations themselves — the header is renderGoModels' job. */ @@ -218,19 +219,17 @@ function renderGoModelBodies(model: ApiModel, dateType: DateType): string { }, '}' ); - printer.block( - 'switch probe.Discriminant {', - () => { - for (const entry of cases.cases) { - printer.block(`case ${JSON.stringify(entry.value)}:`, () => { - printer.line(`var value ${exported(entry.schemaName)}`); - printer.line('err := json.Unmarshal(data, &value)'); - printer.line('return value, err'); - }); - } - }, - '}' - ); + // gofmt keeps `case` at the switch's own indent, so the switch body is NOT + // indented as a block — only each case's statements are. + printer.line('switch probe.Discriminant {'); + for (const entry of cases.cases) { + printer.block(`case ${JSON.stringify(entry.value)}:`, () => { + printer.line(`var value ${exported(entry.schemaName)}`); + printer.line('err := json.Unmarshal(data, &value)'); + printer.line('return value, err'); + }); + } + printer.line('}'); printer.line('var fallback any'); printer.line('err := json.Unmarshal(data, &fallback)'); printer.line('return fallback, err'); @@ -315,6 +314,84 @@ 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'); @@ -980,7 +1057,14 @@ export const goGenerator: Generator = ({ model, outputPath, emit }) => { ); } - return [{ path: outputPath.replace(/\.[^.\\/]+$/, '.go'), content: printer.toString() }]; + return [ + { + path: outputPath.replace(/\.[^.\\/]+$/, '.go'), + // Sections are stitched with their own trailing blanks; gofmt allows at most one + // between declarations and none at the end of the file. + content: `${alignGoColumns(printer.toString().replace(/\n{3,}/g, '\n\n')).trimEnd()}\n`, + }, + ]; }; /** One idiomatic Go call per operation — feeds `x-codeSamples` for docs. */ From 99d13cc5c847c73ec745bec1ab220263eee2b249 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 5 Aug 2026 18:28:51 +0300 Subject: [PATCH 092/211] fix(zod): emit erasable TypeScript so the generated CLI runs under node type stripping --- docs/@v2/guides/use-generated-client.md | 3 + .../src/emitters/__tests__/zod.test.ts | 57 +++++++++++++++++++ packages/client-generator/src/emitters/zod.ts | 20 +++++-- .../src/generators/cli/AGENTS.md | 4 ++ .../src/generators/zod/AGENTS.md | 6 ++ .../generate-client/cli-consumer/.gitignore | 1 + tests/e2e/generate-client/cli.test.ts | 26 +++++++++ 7 files changed, 113 insertions(+), 4 deletions(-) diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index fc48fe9499..efaeffc067 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -219,6 +219,9 @@ Keep the default `js` when the client goes through `tsc` or a bundler — plain Loaders such as `tsx` remap `.js` to `.ts` themselves, so they work with the default. See the [`node-native` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/node-native). +**Every generated TypeScript file is erasable TypeScript**, so type stripping alone is enough — the client, the zod module, and the generated CLI all run under plain `node` with no build step. +Nothing emitted needs a transform to become JavaScript: no `enum`, no `namespace`, and no constructor parameter properties (`constructor(readonly id: string)`), which strip-only mode rejects because it would have to generate assignments. + ## Authentication Credentials are **per instance**: they live in the client's config (`ClientConfig.auth`), and each operation automatically sends the credentials its `security` requires. diff --git a/packages/client-generator/src/emitters/__tests__/zod.test.ts b/packages/client-generator/src/emitters/__tests__/zod.test.ts index 7931f55dcc..82aecf3898 100644 --- a/packages/client-generator/src/emitters/__tests__/zod.test.ts +++ b/packages/client-generator/src/emitters/__tests__/zod.test.ts @@ -441,3 +441,60 @@ describe('schemaToZodExpression — direct export', () => { expect(schemaToZodExpression({ kind: 'scalar', scalar: 'string' })).toBe('z.string()'); }); }); + +describe('erasable TypeScript', () => { + // The generated CLI imports this module and runs under `node + // --experimental-strip-types`, which rejects anything needing a transform. + const out = renderZodModule( + apiModel({ + schemas: [ + { + name: 'Order', + schema: { + kind: 'object', + properties: [ + { name: 'id', schema: { kind: 'scalar', scalar: 'string' }, required: true }, + ], + }, + }, + ], + services: [ + { + name: 'Orders', + operations: [ + operation({ + name: 'createOrder', + method: 'post', + requestBody: { + contentType: 'application/json', + required: true, + schema: { kind: 'ref', name: 'Order' }, + }, + successResponses: [response({ schema: { kind: 'ref', name: 'Order' } })], + }), + ], + }, + ], + }) + ); + + it('declares error fields instead of using constructor parameter properties', () => { + expect(out).toContain('class ZodValidationError'); + // `constructor(readonly x: string)` fails strip-only mode. + expect(out).not.toMatch(/constructor\([^)]*\breadonly\b/s); + expect(out).toContain('readonly operationId: string;'); + expect(out).toContain('this.operationId = operationId;'); + }); + + it('emits no construct that type stripping cannot erase', () => { + for (const construct of [ + /\benum /, + /\bnamespace /, + /\bdeclare /, + /\bprivate /, + /\bprotected /, + ]) { + expect(out).not.toMatch(construct); + } + }); +}); diff --git a/packages/client-generator/src/emitters/zod.ts b/packages/client-generator/src/emitters/zod.ts index 576452f9e9..ced0f512e4 100644 --- a/packages/client-generator/src/emitters/zod.ts +++ b/packages/client-generator/src/emitters/zod.ts @@ -284,11 +284,19 @@ export type ZodViolation = { path: string; message: string; received: string }; /** A request or response payload failed validation. Requests throw it; response handling is configurable. */ export class ZodValidationError extends Error { + // Declared and assigned in the body, NOT as constructor parameter properties: those + // need a transform, so they break \`node --experimental-strip-types\` for anything + // importing this module (the generated CLI runs that way). + readonly operationId: string; + readonly direction: "request" | "response"; + readonly issues: z.ZodError["issues"]; + readonly violations: ZodViolation[]; + constructor( - readonly operationId: string, - readonly direction: "request" | "response", - readonly issues: z.ZodError["issues"], - readonly violations: ZodViolation[] + operationId: string, + direction: "request" | "response", + issues: z.ZodError["issues"], + violations: ZodViolation[] ) { const detail = violations .slice(0, 5) @@ -296,6 +304,10 @@ export class ZodValidationError extends Error { .join("; "); const more = violations.length > 5 ? \`; …and \${violations.length - 5} more\` : ""; super(\`\${direction === "request" ? "Request" : "Response"} validation failed for operation "\${operationId}": \${detail}\${more}\`); + this.operationId = operationId; + this.direction = direction; + this.issues = issues; + this.violations = violations; this.name = "ZodValidationError"; } } diff --git a/packages/client-generator/src/generators/cli/AGENTS.md b/packages/client-generator/src/generators/cli/AGENTS.md index 5f7957b02f..3457c3b98c 100644 --- a/packages/client-generator/src/generators/cli/AGENTS.md +++ b/packages/client-generator/src/generators/cli/AGENTS.md @@ -20,6 +20,10 @@ with `--help`, a `schema ` introspection command, and `--dry-run`. - **Co-selection aware:** with `zod` selected, requests validate before the network (exit 3); without it, the CLI still works. - Throw-mode only — the exit-code mapping reads thrown `ApiError`s. +- **Runs under `node --experimental-strip-types` with no build step**, including the + modules it imports (the sdk and the zod module). Anything emitted must be erasable + TypeScript; a parameter property anywhere in that import graph breaks the zero-build + runner. ## Emitters that implement it diff --git a/packages/client-generator/src/generators/zod/AGENTS.md b/packages/client-generator/src/generators/zod/AGENTS.md index d6512bc88b..e5f6335d19 100644 --- a/packages/client-generator/src/generators/zod/AGENTS.md +++ b/packages/client-generator/src/generators/zod/AGENTS.md @@ -16,6 +16,12 @@ A standalone `.zod.ts`: one `export const Schema` per named IR schem - **Emits nothing** when the model has neither named schemas nor JSON operation bodies — an empty file is worse than no file. - Validation is opt-in at runtime (`use(zodValidation())`), never automatic. +- **Only ERASABLE TypeScript.** The module must run under `node --experimental-strip-types` + with no build step, so nothing that needs a transform is emitted: no `enum`, no + `namespace`, and no constructor parameter properties. `ZodValidationError` therefore + declares its fields and assigns them in the constructor body — `constructor(readonly +operationId: string)` fails strip-only mode, which is how the generated CLI broke when it + imported this module. ## Emitters that implement it diff --git a/tests/e2e/generate-client/cli-consumer/.gitignore b/tests/e2e/generate-client/cli-consumer/.gitignore index 684bec4c9f..15450ac8ad 100644 --- a/tests/e2e/generate-client/cli-consumer/.gitignore +++ b/tests/e2e/generate-client/cli-consumer/.gitignore @@ -1 +1,2 @@ client/ +client-strip/ diff --git a/tests/e2e/generate-client/cli.test.ts b/tests/e2e/generate-client/cli.test.ts index 4143be76d3..38e05491be 100644 --- a/tests/e2e/generate-client/cli.test.ts +++ b/tests/e2e/generate-client/cli.test.ts @@ -9,6 +9,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const fixture = join(__dirname, 'fixtures/cli.yaml'); const consumerDir = join(__dirname, 'cli-consumer'); const clientDir = join(consumerDir, 'client'); +const stripDir = join(consumerDir, 'client-strip'); const SERVER_PORT = 3108; const SERVER_BASE = `http://127.0.0.1:${SERVER_PORT}`; @@ -47,6 +48,18 @@ describe('generate-client cli generator (end-to-end)', () => { 'cli', ]); writeFileSync(join(clientDir, 'package.json'), JSON.stringify({ type: 'module' }), 'utf-8'); + // A second copy with `.ts` specifiers: what a zero-build `node` runner needs. + generate(fixture, join(stripDir, 'client.ts'), [ + '--generator', + 'sdk', + '--generator', + 'zod', + '--generator', + 'cli', + '--import-ext', + 'ts', + ]); + writeFileSync(join(stripDir, 'package.json'), JSON.stringify({ type: 'module' }), 'utf-8'); serverProcess = await startServer( join(consumerDir, 'server.ts'), consumerDir, @@ -59,6 +72,7 @@ describe('generate-client cli generator (end-to-end)', () => { afterAll(async () => { if (serverProcess) await killServer(serverProcess); rmSync(clientDir, { recursive: true, force: true }); + rmSync(stripDir, { recursive: true, force: true }); }); it('generates client.cli.ts and strict tsc (types: node) accepts it', () => { @@ -167,6 +181,18 @@ describe('generate-client cli generator (end-to-end)', () => { expect(help.stdout).toContain('orders'); }); + it('runs under node type stripping with no build step, zod included', () => { + // Erasable TypeScript only: a constructor parameter property anywhere in the import + // graph (it was in the zod module's error class) breaks strip-only mode. + const result = spawnSync( + process.execPath, + ['--experimental-strip-types', join(stripDir, 'client.cli.ts'), '--help'], + { encoding: 'utf-8', cwd: consumerDir } + ); + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + expect(result.stdout).toContain('Usage:'); + }); + it('void results print nothing and exit 0', () => { const { code, stdout } = runCliBin(['ping']); expect(code).toBe(0); From 22cd2042c5683dd5d6bdcb5f45f9bbcfad3b46eb Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 5 Aug 2026 18:49:16 +0300 Subject: [PATCH 093/211] feat(php): keep union types instead of collapsing them to mixed --- .../src/generators/__tests__/php.test.ts | 80 ++++++++++++++++++- .../src/generators/php/AGENTS.md | 7 ++ .../src/generators/php/index.ts | 39 ++++++++- 3 files changed, 122 insertions(+), 4 deletions(-) diff --git a/packages/client-generator/src/generators/__tests__/php.test.ts b/packages/client-generator/src/generators/__tests__/php.test.ts index b9f4c1ff23..30963fd5af 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, renderPhpModels } from '../php/index.js'; +import { phpGenerator, phpType, renderPhpModels } from '../php/index.js'; const hasPhp = spawnSync('php', ['--version']).status === 0; @@ -45,6 +45,84 @@ function model(schemas: Record): ApiModel { } as unknown as ApiModel; } +describe('phpType — unions', () => { + const ENUM: SchemaModel = { kind: 'enum', values: ['a', 'b'], scalar: 'string' }; + const base = model({ + Kind: ENUM, + Order: { kind: 'object', properties: [] }, + }); + + it('renders a union of expressible members as a native PHP 8.1 union', () => { + expect(phpType({ kind: 'union', members: [STRING, INT] }, base)).toBe('string|int'); + expect( + phpType( + { + kind: 'union', + members: [ + { kind: 'ref', name: 'Kind' }, + { kind: 'array', items: STRING }, + ], + }, + base + ) + ).toBe('Kind|array'); + // A class member keeps its class name. + expect( + phpType({ kind: 'union', members: [{ kind: 'ref', name: 'Order' }, STRING] }, base) + ).toBe('Order|string'); + }); + + it('expresses nullability as |null inside a union — PHP forbids mixing ? with |', () => { + const type = phpType({ kind: 'union', members: [STRING, INT, { kind: 'null' }] }, base); + expect(type).toBe('string|int|null'); + expect(type.startsWith('?')).toBe(false); + // A single nullable type keeps the shorthand. + expect(phpType({ kind: 'union', members: [STRING, { kind: 'null' }] }, base)).toBe('?string'); + }); + + it('makes an OPTIONAL union nullable with |null, never a leading ?', () => { + const out = renderPhpModels( + model({ + Cash: { kind: 'object', properties: [] }, + Card: { kind: 'object', properties: [] }, + Customer: { + kind: 'object', + properties: [ + { + name: 'instrument', + schema: { + kind: 'union', + members: [ + { kind: 'ref', name: 'Cash' }, + { kind: 'ref', name: 'Card' }, + ], + }, + required: false, + }, + ], + }, + }) + ); + expect(out).toContain('public Cash|Card|null $instrument = null'); + // `?Cash|Card` is a parse error. + expect(out).not.toContain('?Cash|Card'); + expectModelsRun(out); + }); + + it('falls back to mixed when a member has no PHP type — mixed cannot be a union member', () => { + const withInlineObject: SchemaModel = { + kind: 'union', + members: [STRING, { kind: 'object', properties: [] }], + }; + expect(phpType(withInlineObject, base)).toBe('mixed'); + expect(phpType({ kind: 'union', members: [STRING, { kind: 'unknown' }] }, base)).toBe('mixed'); + }); + + it('deduplicates members that map to the same PHP type', () => { + expect(phpType({ kind: 'union', members: [STRING, ENUM] }, base)).toBe('string'); + }); +}); + describe('renderPhpModels', () => { it('renders classes — required first, optionals nullable with defaults, wire maps preserved', () => { const out = renderPhpModels( diff --git a/packages/client-generator/src/generators/php/AGENTS.md b/packages/client-generator/src/generators/php/AGENTS.md index 2f4dd0f393..b44bcd861d 100644 --- a/packages/client-generator/src/generators/php/AGENTS.md +++ b/packages/client-generator/src/generators/php/AGENTS.md @@ -29,6 +29,13 @@ extension — zero Composer dependencies. The namespace derives from the API tit - **Enums** are native backed enums (string/int); other scalars stay aliases. **Discriminated unions** are `match`-based `unmarshalX(array $data)` dispatchers; **allOf** is flattened. +- **Unions keep their types where PHP 8.1 can express them.** A union of scalars, enums, + classes, or arrays becomes a native union type (`int|string`, `PromotionType|array`) + rather than collapsing to `mixed` — rich list filters are the common case and losing + their types loses the point of a typed SDK. It falls back to `mixed` only when a member + has no PHP type of its own (an inline object, an intersection, `unknown`), because + `mixed` cannot appear inside a union. Nullability is expressed as `|null` in a union + (PHP forbids mixing `?` with `|`) and `?T` for a single type. - **Errors:** exceptions ARE the error mode (`ApiError`/`TimeoutError` extend `\RuntimeException`); `errorMode` does not change the output (the generator declares `errorModes: ['throw']`, so `result` fails fast). diff --git a/packages/client-generator/src/generators/php/index.ts b/packages/client-generator/src/generators/php/index.ts index b0ed27d7e8..489ec5406b 100644 --- a/packages/client-generator/src/generators/php/index.ts +++ b/packages/client-generator/src/generators/php/index.ts @@ -87,7 +87,7 @@ export function phpType( ): string { if (isNullable(schema)) { const inner = phpType(unwrapNullable(schema), model, dateType); - return inner === 'mixed' || inner.startsWith('?') ? inner : `?${inner}`; + return phpNullable(inner); } switch (schema.kind) { case 'scalar': @@ -119,6 +119,7 @@ export function phpType( // 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': @@ -139,6 +140,38 @@ function isDateFormat(schema: SchemaModel): boolean { 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, @@ -281,7 +314,7 @@ function writeClass( if (property.required) { printer.line(`public ${type} ${'$'}${propertyName(property.name)},`); } else { - const nullable = type === 'mixed' || type.startsWith('?') ? type : `?${type}`; + const nullable = phpNullable(type); printer.line(`public ${nullable} ${'$'}${propertyName(property.name)} = null,`); } } @@ -522,7 +555,7 @@ function methodArgs( ] : []), ...queryArgs.map(({ php, type }) => { - const nullable = type === 'mixed' || type.startsWith('?') ? type : `?${type}`; + const nullable = phpNullable(type); return `${nullable} ${'$'}${php} = null`; }), '?array $headers = null', From 79026d0e872023d65831ad641f85b3ba01d81305 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 5 Aug 2026 18:54:01 +0300 Subject: [PATCH 094/211] feat: name the declared query parameters when a pagination param does not match --- .../src/emitters/__tests__/client-assembly.test.ts | 2 +- .../src/emitters/__tests__/pagination.test.ts | 4 ++-- packages/client-generator/src/emitters/pagination.ts | 8 +++++++- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts b/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts index 7964d0bd5b..b2ea77313d 100644 --- a/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts +++ b/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts @@ -535,7 +535,7 @@ describe('emitClientSingleFile — pagination', () => { expect(() => emitClientSingleFile(model)).toThrow( 'Invalid pagination configuration:\n' + ' - Pagination for operation "listOrders" (x-redocly-pagination): ' + - 'query parameter "after" is not declared on the operation\n' + + 'query parameter "after" is not declared on the operation (declared: cursor, limit)\n' + ' - Pagination for operation "listRefunds" (x-redocly-pagination): ' + 'the "items" pointer "/refunds" does not resolve in the success response schema' ); diff --git a/packages/client-generator/src/emitters/__tests__/pagination.test.ts b/packages/client-generator/src/emitters/__tests__/pagination.test.ts index fbb6aa7100..3cfa563b27 100644 --- a/packages/client-generator/src/emitters/__tests__/pagination.test.ts +++ b/packages/client-generator/src/emitters/__tests__/pagination.test.ts @@ -469,7 +469,7 @@ describe('resolveOperationPagination — fit verification', () => { [ 'an advance param missing from the query params', { ...CURSOR_RULE, cursorParam: 'after' }, - 'query parameter "after" is not declared on the operation', + 'query parameter "after" is not declared on the operation (declared: cursor, offset, page, limit)', ], [ 'an unresolvable items pointer', @@ -744,7 +744,7 @@ describe('resolveModelPagination', () => { expect(() => resolveModelPagination(modelWith([bad1, bad2]), undefined)).toThrow( 'Invalid pagination configuration:\n' + ' - Pagination for operation "listOrders" (x-redocly-pagination): ' + - 'query parameter "after" is not declared on the operation\n' + + 'query parameter "after" is not declared on the operation (declared: cursor, offset, page, limit)\n' + ' - Pagination for operation "listRefunds" (x-redocly-pagination): ' + '"style" must be one of "cursor" | "offset" | "page" | "link" (got "nope")' ); diff --git a/packages/client-generator/src/emitters/pagination.ts b/packages/client-generator/src/emitters/pagination.ts index 8dea94e9a1..37464a67b2 100644 --- a/packages/client-generator/src/emitters/pagination.ts +++ b/packages/client-generator/src/emitters/pagination.ts @@ -146,7 +146,13 @@ function applyRule( const param = valid.style === 'cursor' ? valid.cursorParam! : valid.offsetParam!; const advance = op.queryParams.find((p) => p.name === param); if (!advance) { - return misfit(`query parameter "${param}" is not declared on the operation`); + // Name what IS declared: the fix is almost always a different spelling + // (`after` vs `cursor`), and the message should make that obvious. + const declared = op.queryParams.map((p) => p.name).join(', '); + return misfit( + `query parameter "${param}" is not declared on the operation` + + (declared === '' ? '' : ` (declared: ${declared})`) + ); } // The advance param must accept what the runtime sends: the response's cursor // (string-ish, same predicate as nextCursor) or the incremented number. From 6ae6e4180ac54a0820ef2f69e0bc0f861ce988e0 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 5 Aug 2026 19:31:45 +0300 Subject: [PATCH 095/211] feat: say why a name was renamed and what the publisher can change --- .../src/generators/sdk/AGENTS.md | 5 ++ .../__tests__/sanitize-identifiers.test.ts | 55 ++++++++++++++++++ .../sanitize-identifiers.ts | 57 ++++++++++++++++--- 3 files changed, 109 insertions(+), 8 deletions(-) diff --git a/packages/client-generator/src/generators/sdk/AGENTS.md b/packages/client-generator/src/generators/sdk/AGENTS.md index ee22a902bc..e97f632f86 100644 --- a/packages/client-generator/src/generators/sdk/AGENTS.md +++ b/packages/client-generator/src/generators/sdk/AGENTS.md @@ -22,6 +22,11 @@ and either the embedded runtime (`runtime: inline`) or imports from - **Zero runtime dependencies.** `Date`, `Blob`, `fetch` — nothing else. - **Names are collision-safe:** `packageIdents` seeds every reserved wiring name before any operation is sanitized, so renames are deterministic (`configure` → `configure_2`). + A rename becomes part of the SDK's public API, so the warning must say WHICH cause it + is and what the publisher can do: a duplicate `operationId` in the description (fix the + description — the only real fix), a name that isn't a valid identifier, or a clash with + a name the generated module already declares. A vague "collides or is invalid" message + leaves the publisher unable to act. - **Throw mode returns the body**; `{ envelope: true }` opts into `{ data, headers, response }` with typed declared headers. Result mode returns `{ data, error, response }` and ignores `envelope`. diff --git a/packages/client-generator/src/intermediate-representation/__tests__/sanitize-identifiers.test.ts b/packages/client-generator/src/intermediate-representation/__tests__/sanitize-identifiers.test.ts index 9b8d324a0e..88911955e4 100644 --- a/packages/client-generator/src/intermediate-representation/__tests__/sanitize-identifiers.test.ts +++ b/packages/client-generator/src/intermediate-representation/__tests__/sanitize-identifiers.test.ts @@ -1,3 +1,5 @@ +import { logger } from '@redocly/openapi-core'; + import type { ApiModel, OperationModel, SchemaModel } from '../model.js'; import { assertPathParamsAvoidArgSlots, @@ -356,3 +358,56 @@ describe('assertSafeIdentifiers', () => { ); }); }); + +describe('rename warnings name the cause and the fix', () => { + function warningsFor(build: () => void): string { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + try { + build(); + return warn.mock.calls.map(([message]) => message).join(''); + } finally { + warn.mockRestore(); + } + } + + it('says the description has a duplicate operationId — the only real fix', () => { + const messages = warningsFor(() => + sanitizeIdentifiers( + model([], [op({ name: 'patchCreditMemo' }), op({ name: 'patchCreditMemo' })]) + ) + ); + expect(messages).toContain('two operations share the operationId "patchCreditMemo"'); + expect(messages).toContain('patchCreditMemo_2'); + expect(messages).toContain('give each operation a unique operationId'); + // The old message blamed the identifier and offered nothing to act on. + expect(messages).not.toContain('is not a valid TypeScript identifier'); + }); + + it('says which reserved name a schema clashed with', () => { + const messages = warningsFor(() => + sanitizeIdentifiers(model([{ name: 'Error', schema: { kind: 'unknown' } }])) + ); + expect(messages).toContain('schema "Error"'); + expect(messages).toContain('a name the generated client already declares'); + expect(messages).toContain('Error_2'); + }); + + it('says a name was not a usable identifier when that is the actual cause', () => { + const messages = warningsFor(() => + sanitizeIdentifiers(model([{ name: 'not a name!', schema: { kind: 'unknown' } }])) + ); + expect(messages).toContain('is not a usable identifier'); + }); + it('says an operation collided with a schema of the same name — the common real case', () => { + const messages = warningsFor(() => + sanitizeIdentifiers( + model( + [{ name: 'PatchCreditMemo', schema: { kind: 'unknown' } }], + [op({ name: 'PatchCreditMemo' })] + ) + ) + ); + expect(messages).toContain('collides with the schema of the same name'); + expect(messages).toContain('rename the operation or the schema in the description'); + }); +}); diff --git a/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts b/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts index b6f02bae21..6da75802dd 100644 --- a/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts +++ b/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts @@ -36,7 +36,7 @@ export function sanitizeIdentifiers(model: ApiModel): void { const safe = uniquePascalIdent(scheme.key, schemeKeys, schemePascals); if (safe !== scheme.key) { renamedKeys.set(scheme.key, safe); - warnRename('security scheme', scheme.key, safe); + warnRename('security scheme', scheme.key, safe, causeFor(scheme.key, schemeKeys)); scheme.key = safe; } } @@ -46,17 +46,18 @@ export function sanitizeIdentifiers(model: ApiModel): void { // a satellite import like msw's `http`, the `client` const, an auth setter, …). The // rename is mode-independent — a `--runtime` flip must not change the generated // type names. - const schemaNames = new Set([ + const reservedNames = new Set([ ...reservedModuleNames(), ...authSetterNames(model.securitySchemes), ]); + const schemaNames = new Set(reservedNames); const schemaPascals = new Set(); const renamed = new Map(); for (const schema of model.schemas) { const safe = uniquePascalIdent(schema.name, schemaNames, schemaPascals); if (safe !== schema.name) { renamed.set(schema.name, safe); - warnRename('schema', schema.name, safe); + warnRename('schema', schema.name, safe, causeFor(schema.name, reservedNames)); schema.name = safe; } } @@ -73,11 +74,24 @@ export function sanitizeIdentifiers(model: ApiModel): void { // shadowed type. Seeding with the schema names renames a colliding operation. const operationNames = new Set(schemaNames); const operationPascals = new Set(); + const seenOperationIds = new Set(); for (const service of model.services) { for (const op of service.operations) { + // Recorded for EVERY operation, renamed or not: the second occurrence of an + // operationId is what identifies a duplicate in the description. + const duplicate = seenOperationIds.has(op.name); + seenOperationIds.add(op.name); + const original = op.name; const safe = uniquePascalIdent(op.name, operationNames, operationPascals); if (safe !== op.name) { - warnRename('operation', op.name, safe); + const cause: RenameCause = duplicate + ? { kind: 'duplicate-operation-id' } + : reservedNames.has(original) + ? { kind: 'reserved' } + : schemaNames.has(original) + ? { kind: 'schema-collision' } + : { kind: 'unusable' }; + warnRename('operation', original, safe, cause); op.specName = op.name; op.name = safe; } @@ -167,10 +181,37 @@ function sanitizeRef(name: string): string { return sanitizeIdentifier(name); } -function warnRename(kind: string, from: string, to: string): void { - logger.warn( - `generate-client: ${kind} name ${JSON.stringify(from)} collides with another name or is not a valid TypeScript identifier; using ${JSON.stringify(to)}.\n` - ); +/** + * Why a name had to change, so the publisher can act: a rename becomes part of the + * generated SDK's public API, and "collides or is invalid" tells them nothing. + */ +type RenameCause = + /** Two operations in the description share one operationId — fixable only there. */ + | { kind: 'duplicate-operation-id' } + /** The name is already declared by the generated module (runtime, wiring, imports). */ + | { kind: 'reserved' } + /** An operation and a schema in the same description want the same emitted name. */ + | { kind: 'schema-collision' } + /** The name isn't a usable identifier in the target language. */ + | { kind: 'unusable' }; + +function warnRename(what: string, from: string, to: string, cause: RenameCause): void { + const name = JSON.stringify(from); + const renamed = JSON.stringify(to); + const explanation = + cause.kind === 'duplicate-operation-id' + ? `two operations share the operationId ${name}; the second is emitted as ${renamed} — give each operation a unique operationId to control its method name` + : cause.kind === 'schema-collision' + ? `${what} ${name} collides with the schema of the same name, so it is emitted as ${renamed} — rename the operation or the schema in the description to control its name` + : cause.kind === 'reserved' + ? `${what} ${name} is a name the generated client already declares, so it is emitted as ${renamed}` + : `${what} ${name} is not a usable identifier, so it is emitted as ${renamed}`; + logger.warn(`generate-client: ${explanation}.\n`); +} + +/** Whether a name changed only because something else already claimed it. */ +function causeFor(from: string, taken: ReadonlySet): RenameCause { + return taken.has(from) ? { kind: 'reserved' } : { kind: 'unusable' }; } /** Rewrite `ref`/`omit`/discriminator targets in a schema subtree via `fixRef` (mutates). */ From e5658f9a3c36e38e501a9a3421a366a5e9e7ea15 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 5 Aug 2026 20:05:12 +0300 Subject: [PATCH 096/211] feat(cli): document global flags in help, address commands the way a shell allows --- docs/@v2/guides/use-generated-client.md | 4 + .../src/emitters/runtime-sources.ts | 6 +- .../src/generators/cli/AGENTS.md | 10 +++ .../src/runtime/__tests__/cli.test.ts | 80 ++++++++++++++++++ packages/client-generator/src/runtime/cli.ts | 82 ++++++++++++++++--- 5 files changed, 167 insertions(+), 15 deletions(-) diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index efaeffc067..724660e707 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -41,6 +41,10 @@ npx tsx src/client.cli.ts orders listOrders --page-all # one JSON page per lin npx tsx src/client.cli.ts schema createOrder # request/response schemas ``` +`--help` lists the commands, and for tagged APIs those are grouped: run ` --help` for one command's flags. +An operationId also works on its own (` listOrders`) when it is unambiguous, so you don't have to know its group. +Every global flag appears under `Global flags:` in the top-level help — `--server-url`, `--format json|ndjson`, `--dry-run`, `--page-all`, `--output`, `--token`, `--json` — together with the environment variables the CLI reads. + Credentials come from environment variables derived from the file stem (constant-cased): bearer → `_TOKEN` (or `--token`), basic → `_USERNAME`/`_PASSWORD`, apiKey → `_API_KEY_`. `--server-url` overrides the baked server; `--dry-run` prints the prepared request (credentials redacted) without sending it; blob responses require `--output `; SSE operations stream events as one JSON object per line. diff --git a/packages/client-generator/src/emitters/runtime-sources.ts b/packages/client-generator/src/emitters/runtime-sources.ts index 1296c8e541..8f4f225ffb 100644 --- a/packages/client-generator/src/emitters/runtime-sources.ts +++ b/packages/client-generator/src/emitters/runtime-sources.ts @@ -25,7 +25,7 @@ export const RUNTIME_SOURCES = { 'paginate.ts': "import type { OperationArgs } from './create-client.js';\nimport type { PaginationSpec, QueryValue, RequestOptions } from './types.js';\n\n/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `params` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nexport function resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `params[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nexport async function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.params?.[spec.param];\n while (true) {\n const params = { ...args.params };\n if (cursor !== undefined) params[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, params }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `params[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.params?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call(\n { ...args, params: { ...args.params, [spec.param]: position } },\n init\n );\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nexport async function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\nexport type LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nexport function linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nexport async function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let params = args.params;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, params }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n params = { ...args.params, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nexport async function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n", 'cli.ts': - "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. */\n positionals: Array<{ name: string; description?: string }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n binName: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const groups = new Set(commands.filter((c) => c.group).map((c) => c.group as string));\n let command: CliCommand | undefined;\n let rest: string[];\n if (groups.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n command = commands.find((c) => c.group === undefined && c.name === argv[0]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]}` };\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nfunction envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [command.group] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${flag.description ?? ''}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope = typeof topic === 'string' ? commands.filter((c) => c.group === topic) : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n if (seenGroups.has(command.group)) continue;\n seenGroups.add(command.group);\n lines.push(` ${command.group} `);\n continue;\n }\n lines.push(\n ` ${[command.group, command.name].filter(Boolean).join(' ')} ${command.summary ?? ''}`.trimEnd()\n );\n }\n lines.push(\n '',\n `Run ${binName} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: CliCommand[],\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(commands, wiring.binName, invocation.topic)) stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(invocation.command.schemas ?? {}, null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", + "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. */\n positionals: Array<{ name: string; description?: string }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n binName: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nfunction groupSlug(group: string): string {\n return group\n .trim()\n .replace(/[^A-Za-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .toLowerCase();\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nfunction envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist.\n const prefix = envPrefix(binName);\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ' --token Bearer token',\n ` --json Request body`,\n '',\n 'Environment:',\n ` ${prefix}_TOKEN, ${prefix}_USERNAME/${prefix}_PASSWORD, ${prefix}_API_KEY_`,\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: CliCommand[],\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(commands, wiring.binName, invocation.topic)) stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(invocation.command.schemas ?? {}, null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", } as const; /** Inline-embed variants: imports dropped, `export` stripped outside the kept surface. */ @@ -55,7 +55,7 @@ export const RUNTIME_SOURCES_STRIPPED = { 'paginate.ts': "/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `params` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nfunction resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `params[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nasync function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.params?.[spec.param];\n while (true) {\n const params = { ...args.params };\n if (cursor !== undefined) params[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, params }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `params[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.params?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call(\n { ...args, params: { ...args.params, [spec.param]: position } },\n init\n );\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nasync function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\ntype LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nfunction linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nasync function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let params = args.params;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, params }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n params = { ...args.params, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nasync function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}", 'cli.ts': - "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\ntype CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\ntype CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. */\n positionals: Array<{ name: string; description?: string }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\ntype CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\ntype CliWiring = {\n binName: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\ntype CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nfunction parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const groups = new Set(commands.filter((c) => c.group).map((c) => c.group as string));\n let command: CliCommand | undefined;\n let rest: string[];\n if (groups.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n command = commands.find((c) => c.group === undefined && c.name === argv[0]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]}` };\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nfunction envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [command.group] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${flag.description ?? ''}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope = typeof topic === 'string' ? commands.filter((c) => c.group === topic) : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n if (seenGroups.has(command.group)) continue;\n seenGroups.add(command.group);\n lines.push(` ${command.group} `);\n continue;\n }\n lines.push(\n ` ${[command.group, command.name].filter(Boolean).join(' ')} ${command.summary ?? ''}`.trimEnd()\n );\n }\n lines.push(\n '',\n `Run ${binName} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nasync function runCli(\n commands: CliCommand[],\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(commands, wiring.binName, invocation.topic)) stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(invocation.command.schemas ?? {}, null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}", + "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\ntype CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\ntype CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. */\n positionals: Array<{ name: string; description?: string }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\ntype CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\ntype CliWiring = {\n binName: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\ntype CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nfunction groupSlug(group: string): string {\n return group\n .trim()\n .replace(/[^A-Za-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .toLowerCase();\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nfunction parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nfunction envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist.\n const prefix = envPrefix(binName);\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ' --token Bearer token',\n ` --json Request body`,\n '',\n 'Environment:',\n ` ${prefix}_TOKEN, ${prefix}_USERNAME/${prefix}_PASSWORD, ${prefix}_API_KEY_`,\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nasync function runCli(\n commands: CliCommand[],\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(commands, wiring.binName, invocation.topic)) stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(invocation.command.schemas ?? {}, null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}", } as const; export type RuntimeModuleName = keyof typeof RUNTIME_SOURCES; @@ -125,6 +125,7 @@ export const RUNTIME_DECLARED_NAMES = [ 'encodeReserved', 'envPrefix', 'execute', + 'groupSlug', 'isConfigured', 'items', 'itemsByLink', @@ -134,6 +135,7 @@ export const RUNTIME_DECLARED_NAMES = [ 'loadBody', 'mergeSetup', 'middlewareChain', + 'oneLine', 'pageCall', 'pages', 'pagesByLink', diff --git a/packages/client-generator/src/generators/cli/AGENTS.md b/packages/client-generator/src/generators/cli/AGENTS.md index 3457c3b98c..930b1e9a06 100644 --- a/packages/client-generator/src/generators/cli/AGENTS.md +++ b/packages/client-generator/src/generators/cli/AGENTS.md @@ -12,6 +12,16 @@ with `--help`, a `schema ` introspection command, and `--dry-run`. - **Argument shape:** path params positional, query params typed `--kebab-name` flags, JSON bodies via `--json '' | @file | @-` (stdin). +- **Help is the whole interface.** A flag that exists but isn't in `--help` doesn't exist + to the user, so the top-level help carries a `Global flags:` section (`--server-url`, + `--format`, `--dry-run`, `--page-all`, `--output`, `--token`, `--json`) plus the + credential environment variables. Descriptions are collapsed to ONE line — an OpenAPI + description with newlines otherwise breaks the alignment of every following flag. The + footer names the form that actually works for a grouped API + (` --help`). +- **Commands are addressable the way a shell allows.** A group slug is kebab-cased so a + multi-word OpenAPI tag can be typed without quoting, while help shows the original tag. + A bare operationId resolves to its grouped command when unambiguous. - **Exit codes are a contract:** 0 ok, 1 API error, 2 auth, 3 validation, 4 usage. Errors print ONE JSON object to stderr so stdout stays pipeable. - **Credentials come from the environment** (a stem-derived prefix, e.g. diff --git a/packages/client-generator/src/runtime/__tests__/cli.test.ts b/packages/client-generator/src/runtime/__tests__/cli.test.ts index 060e50c778..09b1625f3c 100644 --- a/packages/client-generator/src/runtime/__tests__/cli.test.ts +++ b/packages/client-generator/src/runtime/__tests__/cli.test.ts @@ -336,3 +336,83 @@ describe('runCli', () => { expect(commandText).toContain('List orders.'); }); }); + +describe('help output', () => { + const MULTILINE: CliCommand = { + group: 'Some multi-word tag', + name: 'listThings', + summary: 'List things.', + method: 'GET', + path: '/things', + positionals: [], + flags: [ + { + name: 'cursor', + param: 'cursor', + type: 'string', + required: false, + description: + 'Cursor value for pagination.\nReturns items starting at this cursor.\n\nSee the guide.', + }, + ], + }; + + async function help(argv: string[], commands = COMMANDS) { + const { wiring, out } = fakeWiring(); + const code = await runCli(commands, wiring, argv); + return { code, text: out.join('\n') }; + } + + it('lists every global flag and the credential env vars', async () => { + const { code, text } = await help(['--help']); + expect(code).toBe(0); + expect(text).toContain('Global flags:'); + for (const flag of [ + '--server-url', + '--format', + '--dry-run', + '--page-all', + '--output', + '--token', + '--json', + ]) { + expect(text).toContain(flag); + } + // The env vars are how credentials actually get in. + expect(text).toContain('_TOKEN'); + }); + + it('points at the grouped form in the footer, since a bare command fails for grouped APIs', async () => { + const { text } = await help(['--help']); + expect(text).toContain(' --help'); + }); + + it('collapses a multiline flag description onto one line', async () => { + const { text } = await help(['some-multi-word-tag', 'listThings', '--help'], [MULTILINE]); + const cursorLine = text.split('\n').find((line) => line.includes('--cursor')); + expect(cursorLine).toContain( + 'Cursor value for pagination. Returns items starting at this cursor. See the guide.' + ); + expect(text.split('\n').filter((line) => line.startsWith('Returns items'))).toEqual([]); + }); + + it('addresses a multi-word tag by its kebab slug while showing the original title', async () => { + const { code, text } = await help(['--help'], [MULTILINE]); + expect(code).toBe(0); + // Typed without quoting… + expect(text).toContain('some-multi-word-tag'); + // …but the human name is still shown. + expect(text).toContain('Some multi-word tag'); + expect(parseInvocation([MULTILINE], ['some-multi-word-tag', 'listThings'])).toMatchObject({ + kind: 'run', + command: MULTILINE, + }); + }); + + it('resolves a bare operationId to its grouped command', () => { + expect(parseInvocation(COMMANDS, ['getOrder', 'ord_1'])).toMatchObject({ + kind: 'run', + command: GET, + }); + }); +}); diff --git a/packages/client-generator/src/runtime/cli.ts b/packages/client-generator/src/runtime/cli.ts index b8063863f0..7a5ca33a18 100644 --- a/packages/client-generator/src/runtime/cli.ts +++ b/packages/client-generator/src/runtime/cli.ts @@ -86,6 +86,24 @@ const GLOBAL_FLAGS: Record json: { key: 'json' }, }; +/** + * 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. + */ +function groupSlug(group: string): string { + return group + .trim() + .replace(/[^A-Za-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .toLowerCase(); +} + +/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */ +function oneLine(text: string): string { + return text.replace(/\s+/g, ' ').trim(); +} + /** Resolve argv against the command table. Pure — no I/O, no env. */ export function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation { if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' }; @@ -97,17 +115,31 @@ export function parseInvocation(commands: CliCommand[], argv: string[]): CliInvo : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() }; } - const groups = new Set(commands.filter((c) => c.group).map((c) => c.group as string)); + const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string))); let command: CliCommand | undefined; let rest: string[]; - if (groups.has(argv[0])) { + if (slugs.has(argv[0])) { if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] }; - command = commands.find((c) => c.group === argv[0] && c.name === argv[1]); + command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]); if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` }; rest = argv.slice(2); } else { - command = commands.find((c) => c.group === undefined && c.name === argv[0]); - if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]}` }; + // An ungrouped command, or a bare operationId — knowing the group shouldn't be + // required when the name alone is unambiguous. + const named = commands.filter((c) => c.name === argv[0]); + command = + named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined); + if (!command) { + const ambiguous = named.length > 1; + return { + kind: 'usage-error', + message: ambiguous + ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named + .map((c) => groupSlug(c.group as string)) + .join(', ')})` + : `Unknown command: ${argv[0]}`, + }; + } rest = argv.slice(1); } if (rest.includes('--help')) return { kind: 'help', topic: command }; @@ -247,7 +279,7 @@ function renderHelp( const command = topic; const usage = [ binName, - ...(command.group ? [command.group] : []), + ...(command.group ? [groupSlug(command.group)] : []), command.name, ...command.positionals.map((slot) => `<${slot.name}>`), ...(command.flags.length > 0 ? ['[flags]'] : []), @@ -261,32 +293,56 @@ function renderHelp( const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : ''; const required = flag.required ? ' [required]' : ''; lines.push( - ` --${flag.name} <${flag.type}>${choices}${required} ${flag.description ?? ''}`.trimEnd() + ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd() ); } } return lines; } - const scope = typeof topic === 'string' ? commands.filter((c) => c.group === topic) : commands; + const scope = + typeof topic === 'string' + ? commands.filter((c) => c.group && groupSlug(c.group) === topic) + : commands; const lines = typeof topic === 'string' ? [`Usage: ${binName} ${topic} …`, '', 'Commands:'] : [`Usage: ${binName} [group] …`, '', 'Commands:']; const seenGroups = new Set(); + const grouped = commands.some((c) => c.group); for (const command of scope) { if (typeof topic !== 'string' && command.group) { - if (seenGroups.has(command.group)) continue; - seenGroups.add(command.group); - lines.push(` ${command.group} `); + const slug = groupSlug(command.group); + if (seenGroups.has(slug)) continue; + seenGroups.add(slug); + // The slug is what you type; the tag is what you recognize. + const title = slug === command.group ? '' : ` (${command.group})`; + lines.push(` ${slug} ${title}`); continue; } lines.push( - ` ${[command.group, command.name].filter(Boolean).join(' ')} ${command.summary ?? ''}`.trimEnd() + ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name] + .filter(Boolean) + .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd() ); } + // Flags that apply to every command, and the env vars credentials come from: a flag + // absent from --help may as well not exist. + const prefix = envPrefix(binName); lines.push( '', - `Run ${binName} --help for command details; ${binName} schema prints its schemas.` + 'Global flags:', + ' --server-url Override the baked server URL', + ' --format Output format', + ' --dry-run Print the prepared request without sending it', + ' --page-all Follow pagination, one JSON page per line', + ' --output Write the response body to a file (required for binary)', + ' --token Bearer token', + ` --json Request body`, + '', + 'Environment:', + ` ${prefix}_TOKEN, ${prefix}_USERNAME/${prefix}_PASSWORD, ${prefix}_API_KEY_`, + '', + `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.` ); return lines; } From d415bbf8a8988807be21270df3eacff1f301185e Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 5 Aug 2026 20:13:40 +0300 Subject: [PATCH 097/211] feat(cli): give the generated CLI a command-like bin name, settable via binName --- packages/cli/src/commands/generate-client.ts | 2 ++ packages/cli/src/index.ts | 6 +++++ .../src/emitters/emit-options.ts | 7 ++++++ .../src/generators/__tests__/cli.test.ts | 24 +++++++++++++++++++ .../src/generators/cli/AGENTS.md | 4 ++++ .../src/generators/cli/index.ts | 12 +++++++++- packages/client-generator/src/pipeline.ts | 1 + packages/client-generator/src/types.ts | 2 ++ .../__snapshots__/redocly-yaml.test.ts.snap | 3 +++ packages/core/src/types/redocly-yaml.ts | 1 + 10 files changed, 61 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/generate-client.ts b/packages/cli/src/commands/generate-client.ts index eb4a7f8175..2f1e35e777 100644 --- a/packages/cli/src/commands/generate-client.ts +++ b/packages/cli/src/commands/generate-client.ts @@ -22,6 +22,7 @@ export type GenerateClientCommandArgv = { 'output-mode'?: 'single' | 'split'; runtime?: 'inline' | 'package'; 'import-ext'?: 'js' | 'ts'; + 'bin-name'?: string; 'args-style'?: 'flat' | 'grouped'; 'error-mode'?: 'throw' | 'result'; 'date-type'?: 'string' | 'Date'; @@ -77,6 +78,7 @@ export async function handleGenerateClient({ outputMode: argv['output-mode'], runtime: argv.runtime, importExt: argv['import-ext'], + binName: argv['bin-name'], argsStyle: argv['args-style'], errorMode: argv['error-mode'], dateType: argv['date-type'], diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index c791b6ba4a..1941d223d3 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -899,6 +899,12 @@ yargs(hideBin(process.argv)) choices: ['inline', 'package'] as const, requiresArg: true, }, + 'bin-name': { + description: + "Command name for the `cli` generator's help output and credential env vars. Defaults to the output stem.", + type: 'string', + requiresArg: true, + }, 'import-ext': { describe: "Extension in generated relative imports: 'js' (default) suits tsc and bundlers; 'ts' suits runtimes that resolve specifiers literally, like Node's built-in type stripping (node client.ts).", diff --git a/packages/client-generator/src/emitters/emit-options.ts b/packages/client-generator/src/emitters/emit-options.ts index 13c55f4022..1e72ebb9ae 100644 --- a/packages/client-generator/src/emitters/emit-options.ts +++ b/packages/client-generator/src/emitters/emit-options.ts @@ -57,6 +57,13 @@ export type EmitOptions = { * built-in type stripping (`node client.ts`). */ importExt?: 'js' | 'ts'; + /** + * Command name the `cli` generator uses in help output and to derive its credential + * environment variables. Defaults to the output stem with non-word characters folded + * to `-` — the stem follows the TypeScript file convention, and `openapi.client` reads + * as a filename in a usage line. + */ + binName?: string; /** * Auto-pagination rules (a convention rule + per-operation overrides + `exclude`), * resolved together with each operation's `x-redocly-pagination` extension. Verified diff --git a/packages/client-generator/src/generators/__tests__/cli.test.ts b/packages/client-generator/src/generators/__tests__/cli.test.ts index dca2675f2f..bffbd45d5f 100644 --- a/packages/client-generator/src/generators/__tests__/cli.test.ts +++ b/packages/client-generator/src/generators/__tests__/cli.test.ts @@ -83,3 +83,27 @@ describe('cliGenerator', () => { expect(sample?.source).toContain('Orders getOrder '); }); }); + +describe('bin name', () => { + it('folds the TypeScript stem into a command-like name', () => { + // `openapi.client` in a usage line reads as a filename, and yields OPENAPI_CLIENT_* anyway. + const out = cliGenerator({ + model: MODEL, + outputPath: '/out/openapi.client.ts', + outputMode: 'single', + emit: {}, + })[0].content; + expect(out).toContain('binName: "openapi-client"'); + expect(out).not.toContain('binName: "openapi.client"'); + }); + + it('honors an explicit binName', () => { + const out = cliGenerator({ + model: MODEL, + outputPath: '/out/openapi.client.ts', + outputMode: 'single', + emit: { binName: 'cafe' }, + })[0].content; + expect(out).toContain('binName: "cafe"'); + }); +}); diff --git a/packages/client-generator/src/generators/cli/AGENTS.md b/packages/client-generator/src/generators/cli/AGENTS.md index 930b1e9a06..002550799c 100644 --- a/packages/client-generator/src/generators/cli/AGENTS.md +++ b/packages/client-generator/src/generators/cli/AGENTS.md @@ -24,6 +24,10 @@ with `--help`, a `schema ` introspection command, and `--dry-run`. A bare operationId resolves to its grouped command when unambiguous. - **Exit codes are a contract:** 0 ok, 1 API error, 2 auth, 3 validation, 4 usage. Errors print ONE JSON object to stderr so stdout stays pipeable. +- **The bin name is a command name, not a filename.** It defaults to the output stem with + dots and other non-word characters folded to `-` (`openapi.client` → `openapi-client`), + because the stem follows the TypeScript file convention and a usage line reading + `openapi.client orders …` looks like a path. `client.binName` overrides it. - **Credentials come from the environment** (a stem-derived prefix, e.g. `CLIENT_TOKEN`) or explicit flags; `--dry-run` prints the prepared request with credentials REDACTED. diff --git a/packages/client-generator/src/generators/cli/index.ts b/packages/client-generator/src/generators/cli/index.ts index b6d13389f2..6fe9105b15 100644 --- a/packages/client-generator/src/generators/cli/index.ts +++ b/packages/client-generator/src/generators/cli/index.ts @@ -11,6 +11,16 @@ import type { CodeSample, Generator, SampleContext } from '../types.js'; * bodies, env auth, `--page-all`, SSE/blob output, a documented exit-code * contract). Requires `sdk` (throw mode); wires zod validation when co-selected. */ +/** The stem as a command name: dots and other non-word characters fold to `-`. */ +function commandName(stem: string): string { + return ( + stem + .replace(/[^A-Za-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .toLowerCase() || 'client' + ); +} + export const cliGenerator: Generator = ({ model, outputPath, emit, selected }) => { const { dir, stem } = anchor(outputPath); const content = renderCliModule(model, { @@ -18,7 +28,7 @@ export const cliGenerator: Generator = ({ model, outputPath, emit, selected }) = importExt: emit.importExt ?? 'js', runtime: emit.runtime ?? 'inline', zodSelected: selected?.includes('zod') ?? false, - binName: stem, + binName: emit.binName ?? commandName(stem), pagination: emit.pagination, }); return [{ path: join(dir, `${stem}.cli.ts`), content }]; diff --git a/packages/client-generator/src/pipeline.ts b/packages/client-generator/src/pipeline.ts index 94e08684f6..aaad1f9f6f 100644 --- a/packages/client-generator/src/pipeline.ts +++ b/packages/client-generator/src/pipeline.ts @@ -186,6 +186,7 @@ export async function generateClient( setup: setupBlock, runtime: options.runtime, importExt: options.importExt, + binName: options.binName, pagination: options.pagination, }; // Fail fast on an incompatible selection (missing prerequisite, unsupported diff --git a/packages/client-generator/src/types.ts b/packages/client-generator/src/types.ts index 668a741489..ff83a3bcf5 100644 --- a/packages/client-generator/src/types.ts +++ b/packages/client-generator/src/types.ts @@ -88,6 +88,8 @@ export type GenerateClientOptions = { * `'ts'` suits runtimes that resolve specifiers literally, like Node's built-in * type stripping (`node client.ts`). */ importExt?: 'js' | 'ts'; + /** Command name for the `cli` generator; defaults to the output stem, sanitized. */ + binName?: string; /** * Emit `.code-samples.yaml` — an OpenAPI Overlay adding per-operation * `x-codeSamples` collected from every selected generator that implements `sample()`. 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 a665ffabb4..032f732d18 100644 --- a/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap +++ b/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap @@ -219,6 +219,9 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "grouped", ], }, + "binName": { + "type": "string", + }, "codeSamples": { "type": "boolean", }, diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts index 760d5c4fdf..6220393310 100644 --- a/packages/core/src/types/redocly-yaml.ts +++ b/packages/core/src/types/redocly-yaml.ts @@ -374,6 +374,7 @@ const Client: NodeType = { outputMode: { enum: ['single', 'split'] }, runtime: { enum: ['inline', 'package'] }, importExt: { enum: ['js', 'ts'] }, + binName: { type: 'string' }, errorMode: { enum: ['throw', 'result'] }, dateType: { enum: ['string', 'Date'] }, mockData: { enum: ['static', 'faker'] }, From c335ef6ec4bf5a4a0b107bdd43a9c30231ddba4d Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 5 Aug 2026 20:22:46 +0300 Subject: [PATCH 098/211] feat: pull in a generator's prerequisites so the CLI validates by default --- .changeset/agent-friendly-generators.md | 2 ++ docs/@v2/guides/use-generated-client.md | 3 +- .../src/generators/__tests__/cli.test.ts | 10 +++--- .../src/generators/__tests__/resolve.test.ts | 13 ++++++++ .../src/generators/cli/AGENTS.md | 6 ++-- .../client-generator/src/generators/meta.ts | 4 ++- .../src/generators/resolve.ts | 33 ++++++++++++++++++- .../generator-contract.test.ts | 9 ++--- 8 files changed, 67 insertions(+), 13 deletions(-) diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index 836facd993..784192dd9e 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -4,3 +4,5 @@ --- Added agent-friendly client generation: built-in `python`, `go`, `php`, and `cli` generators, a language-neutral authoring toolkit, an `eject-generator` command that vendors any built-in generator into your repo together with its design as an agent skill, and verification against large real-world descriptions. + +Selecting a generator now pulls in the generators it depends on: `--generator cli` emits the sdk and zod modules it needs (so the generated CLI validates requests by default and requires `zod` at run time), and `--generator tanstack-query` emits the sdk it wraps. diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index 724660e707..447b1d7332 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -31,7 +31,8 @@ See the [`zod`](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/gener The `cli` generator emits `.cli.ts` — a zero-dependency, bin-ready command-line interface over the generated client. Path params are positional, query params become typed `--kebab-name` flags (enums list their choices in `--help`, array params repeat the flag), and JSON request bodies arrive via `--json ''`, `--json @file.json`, or `--json @-` (stdin). -Requests are validated before they are sent — the `cli` generator brings the validation it needs, so no extra generator has to be selected. +Requests are validated before they are sent — selecting `cli` pulls in the generators it needs (`sdk` and `zod`), so nothing extra has to be listed. +That means the CLI's validation uses [zod](https://zod.dev/) at runtime: install it alongside the generated CLI (`npm i zod`). ```sh redocly generate-client openapi.yaml --output src/client.ts --generator sdk --generator cli diff --git a/packages/client-generator/src/generators/__tests__/cli.test.ts b/packages/client-generator/src/generators/__tests__/cli.test.ts index bffbd45d5f..f0c4b3e3d3 100644 --- a/packages/client-generator/src/generators/__tests__/cli.test.ts +++ b/packages/client-generator/src/generators/__tests__/cli.test.ts @@ -67,13 +67,15 @@ describe('cliGenerator', () => { expect(withZod[0].content).toContain('use(zodValidation());'); }); - it('requires sdk and rejects result mode', () => { + it('declares its prerequisites and rejects result mode', () => { + // `sdk` + `zod` are pulled in by the resolver (see resolve.test.ts); validation + // still refuses a selection whose prerequisites are genuinely absent. + expect(builtinGenerators().get('cli')?.requires).toEqual(['sdk', 'zod']); expect(() => validateGenerators(['cli'], {})).toThrow(/requires the "sdk" generator/); - expect(() => validateGenerators(['sdk', 'cli'], { errorMode: 'result' })).toThrow( + expect(() => validateGenerators(['sdk', 'zod', 'cli'], { errorMode: 'result' })).toThrow( /does not support --error-mode "result"/ ); - expect(() => validateGenerators(['sdk', 'cli'], {})).not.toThrow(); - expect(builtinGenerators().has('cli')).toBe(true); + expect(() => validateGenerators(['sdk', 'zod', 'cli'], {})).not.toThrow(); }); it('renders a shell x-codeSamples snippet per operation', () => { diff --git a/packages/client-generator/src/generators/__tests__/resolve.test.ts b/packages/client-generator/src/generators/__tests__/resolve.test.ts index 46892d3355..4798129c03 100644 --- a/packages/client-generator/src/generators/__tests__/resolve.test.ts +++ b/packages/client-generator/src/generators/__tests__/resolve.test.ts @@ -26,6 +26,19 @@ describe('resolveGenerators', () => { expect(registry.get('route-map')?.run).toBe(noopRun); }); + it('pulls in a generator prerequisite instead of failing on it', async () => { + // `--generator cli` alone should produce a working, validating CLI. + const { selected } = await resolveGenerators(['cli']); + expect(selected).toContain('cli'); + expect(selected).toContain('sdk'); + expect(selected).toContain('zod'); + // A prerequisite runs BEFORE the generator that needs it. + expect(selected.indexOf('sdk')).toBeLessThan(selected.indexOf('cli')); + // An explicit selection is not duplicated or reordered away. + const explicit = await resolveGenerators(['sdk', 'zod', 'cli']); + expect(explicit.selected).toEqual(['sdk', 'zod', 'cli']); + }); + it('accepts a generator declaring the current contract; rejects any other with the fix path', async () => { const current: CustomGenerator = { name: 'ok', run: noopRun, contract: GENERATOR_CONTRACT }; await expect(resolveGenerators(['ok'], { customGenerators: [current] })).resolves.toBeTruthy(); diff --git a/packages/client-generator/src/generators/cli/AGENTS.md b/packages/client-generator/src/generators/cli/AGENTS.md index 002550799c..e611f1040b 100644 --- a/packages/client-generator/src/generators/cli/AGENTS.md +++ b/packages/client-generator/src/generators/cli/AGENTS.md @@ -31,8 +31,10 @@ with `--help`, a `schema ` introspection command, and `--dry-run`. - **Credentials come from the environment** (a stem-derived prefix, e.g. `CLIENT_TOKEN`) or explicit flags; `--dry-run` prints the prepared request with credentials REDACTED. -- **Co-selection aware:** with `zod` selected, requests validate before the network - (exit 3); without it, the CLI still works. +- **Validation is on by default.** The generator declares `requires: ['sdk', 'zod']` and + the pipeline pulls prerequisites in automatically, so `--generator cli` alone produces a + validating CLI — a user shouldn't have to know which other generator provides it. The + consequence is a zod peer dependency at run time, which the docs state. - Throw-mode only — the exit-code mapping reads thrown `ApiError`s. - **Runs under `node --experimental-strip-types` with no build step**, including the modules it imports (the sdk and the zod module). Anything emitted must be erasable diff --git a/packages/client-generator/src/generators/meta.ts b/packages/client-generator/src/generators/meta.ts index 11d2a68148..f0f994fc91 100644 --- a/packages/client-generator/src/generators/meta.ts +++ b/packages/client-generator/src/generators/meta.ts @@ -70,8 +70,10 @@ export const BUILTIN_META: Record = { }, // cli dispatches through the sdk's instance client and relies on thrown ApiError // for its exit-code mapping, so it is sdk-bound and throw-only. + // Validation is part of the CLI's contract (exit code 3), so it requires `zod` — + // the pipeline pulls prerequisites in, so `--generator cli` alone is enough. cli: { - requires: ['sdk'], + requires: ['sdk', 'zod'], errorModes: ['throw'], load: () => import('./cli/index.js').then((m) => ({ run: m.cliGenerator, sample: m.cliSample })), diff --git a/packages/client-generator/src/generators/resolve.ts b/packages/client-generator/src/generators/resolve.ts index 22cec0b9bd..bef1e3e2b8 100644 --- a/packages/client-generator/src/generators/resolve.ts +++ b/packages/client-generator/src/generators/resolve.ts @@ -42,7 +42,10 @@ export async function resolveGenerators( for (const custom of options.customGenerators ?? []) register(registry, custom); const selected: string[] = []; - for (const entry of entries) { + // A prerequisite is pulled in rather than demanded: selecting `cli` should give a + // working CLI without the user knowing which other generators provide its parts. + const entriesWithPrerequisites = expandPrerequisites(entries, options.customGenerators); + for (const entry of entriesWithPrerequisites) { if (registry.has(entry)) { selected.push(entry); continue; @@ -61,6 +64,34 @@ export async function resolveGenerators( return { selected, registry }; } +/** + * The selection with every declared prerequisite included, each before the generator that + * needs it. Only BUILT-IN prerequisites are added: a custom generator's `requires` may + * name anything, and inventing a resolution for it would be guesswork. + */ +function expandPrerequisites(entries: string[], customs: CustomGenerator[] = []): string[] { + const requirementsOf = (name: string): string[] => { + const meta = (BUILTIN_META as Record)[name]; + if (meta !== undefined) return meta.requires ?? []; + return customs.find((custom) => custom.name === name)?.requires ?? []; + }; + const out: string[] = []; + const visiting = new Set(); + const add = (name: string): void => { + if (out.includes(name) || visiting.has(name)) return; + visiting.add(name); + for (const required of requirementsOf(name)) { + // Only auto-add a prerequisite we know how to load; anything else stays the + // user's problem and is reported by `validateSelection`. + if (required in BUILTIN_META) add(required); + } + visiting.delete(name); + if (!out.includes(name)) out.push(name); + }; + for (const entry of entries) add(entry); + return out; +} + /** Validate a custom generator and add it under its name, rejecting collisions. */ function register(registry: Map, custom: CustomGenerator): void { if ( diff --git a/tests/e2e/generate-client/generator-contract.test.ts b/tests/e2e/generate-client/generator-contract.test.ts index 1c7b982bcb..bfcecf1ef0 100644 --- a/tests/e2e/generate-client/generator-contract.test.ts +++ b/tests/e2e/generate-client/generator-contract.test.ts @@ -23,7 +23,7 @@ function run(args: string[]): { status: number | null; out: string } { } describe('generate-client generator compatibility contract', () => { - it('rejects tanstack-query without sdk, naming the fix', () => { + it('pulls in the sdk a wrapper generator needs instead of failing', () => { const dir = mkdtempSync(join(tmpdir(), 'ots-contract-')); const { status, out } = run([ cafe, @@ -32,9 +32,10 @@ describe('generate-client generator compatibility contract', () => { '--generator', 'tanstack-query', ]); - expect(status).not.toBe(0); - expect(out).toMatch(/requires the "sdk" generator/); - expect(out).toMatch(/--generator sdk --generator tanstack-query/); + expect(status, out).toBe(0); + // The wrapper wraps the sdk's functions, so the sdk file has to exist. + expect(existsSync(join(dir, 'c.ts'))).toBe(true); + expect(existsSync(join(dir, 'c.tanstack.ts'))).toBe(true); rmSync(dir, { recursive: true, force: true }); }, 60_000); From c0c60c8d583dd8740bc31be02fa2ed3f596fa946 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 5 Aug 2026 20:52:40 +0300 Subject: [PATCH 099/211] feat: add goPackage so the go SDK declares the package the consumer owns --- .changeset/agent-friendly-generators.md | 2 + docs/@v2/commands/generate-client.md | 2 + docs/@v2/configuration/reference/client.md | 38 +++++++++---------- .../@v2/guides/customize-client-generation.md | 1 + docs/@v2/guides/use-generated-client.md | 1 + packages/cli/src/commands/generate-client.ts | 2 + packages/cli/src/index.ts | 6 +++ .../client-generator/eject-assets/AGENTS.md | 2 + .../client-generator/src/authoring/index.ts | 5 +++ .../src/emitters/emit-options.ts | 6 +++ .../src/generators/__tests__/go.test.ts | 21 ++++++++++ .../src/generators/go/AGENTS.md | 5 +++ .../src/generators/go/index.ts | 18 ++++++++- packages/client-generator/src/pipeline.ts | 1 + packages/client-generator/src/types.ts | 2 + .../__snapshots__/redocly-yaml.test.ts.snap | 3 ++ packages/core/src/types/redocly-yaml.ts | 1 + tests/e2e/generate-client/go.test.ts | 9 ++++- 18 files changed, 104 insertions(+), 21 deletions(-) diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index 784192dd9e..f8bcb6c118 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -6,3 +6,5 @@ Added agent-friendly client generation: built-in `python`, `go`, `php`, and `cli` generators, a language-neutral authoring toolkit, an `eject-generator` command that vendors any built-in generator into your repo together with its design as an agent skill, and verification against large real-world descriptions. Selecting a generator now pulls in the generators it depends on: `--generator cli` emits the sdk and zod modules it needs (so the generated CLI validates requests by default and requires `zod` at run time), and `--generator tanstack-query` emits the sdk it wraps. + +Added `goPackage` (`--go-package`) to set the package clause of the `go` generator's output, and `--bin-name` as the flag form of `binName`. diff --git a/docs/@v2/commands/generate-client.md b/docs/@v2/commands/generate-client.md index af97cf343f..c3a6dfeabc 100644 --- a/docs/@v2/commands/generate-client.md +++ b/docs/@v2/commands/generate-client.md @@ -48,6 +48,8 @@ redocly generate-client [--help] [--version] | `--mock-seed` | number | Seed for `faker`-mode mocks, for reproducible data. Ignored in `static` mode. | | `--server-url` | string | Override the server URL included in the client as its default. Accepts an absolute (`https://api.example.com`) or relative (`/v1`) URL. Defaults to `servers[0].url`. The app can also repoint the client at runtime — `createClient({ serverUrl })` or `configure({ serverUrl })`, see [Authentication](../guides/use-generated-client.md#authentication) in the usage guide. | | `--setup` | string | Path to a publisher setup module that gets included in the client — pre-configure defaults such as the server URL, retries, headers, and middleware, so a published SDK ships with them built in. See [Publisher defaults](../guides/customize-client-generation.md#publisher-defaults). | +| `--bin-name` | string | Command name the `cli` generator prints in help output and uses to derive its credential environment variables. Defaults to the output stem with non-word characters folded to `-`. | +| `--go-package` | string | Package clause of the `go` generator's output. Must be a valid Go package name (lowercase letters, digits, and `_`, not starting with a digit, not a keyword). Default value is `client`. | | `--config` | string | Specify path to the [configuration file](#generate-from-the-configuration-file). | | `--help` | boolean | Show help. | | `--version` | boolean | Show version number. | diff --git a/docs/@v2/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md index bf859cd18e..cc784b5e5a 100644 --- a/docs/@v2/configuration/reference/client.md +++ b/docs/@v2/configuration/reference/client.md @@ -17,25 +17,25 @@ Each scalar option mirrors the matching CLI flag and shares its default — see The `pagination` option is config-only — a structured, durable contract that belongs in versioned configuration rather than a shell string. For runs without a configuration file, declare pagination per operation with the `x-redoclyPagination` extension in the description, or pass `pagination` to the programmatic `generateClient(...)`. -| Option | Type | Description | -| ---------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `generators` | [string] | Generators to run, in order. Each entry is a built-in name (`sdk`, `zod`, `tanstack-query` — or its `-vue`/`-svelte`/`-solid` variants — `swr`, `mock`, `transformers`, `cli`, `python`, `go`, `php`) or a custom generator's path or package name. | -| `outputMode` | string | File layout: `single` or `split`. TypeScript output only — the `python`, `go`, and `php` SDKs always emit one self-contained file. | -| `runtime` | string | Runtime distribution: `inline` or `package`. TypeScript output only — the `python`, `go`, and `php` SDKs always embed their runtime. | -| `importExt` | string | Extension in generated relative imports: `js` (default, for tsc and bundlers) or `ts` (for Node's built-in type stripping). TypeScript output only. | -| `argsStyle` | string | How operation inputs are passed: `flat` or `grouped`. 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 are throw-only (their language idiom IS the error mode) and reject `result`. | -| `dateType` | string | Type of `date`/`date-time` fields: `string` or `Date`. Every language honors it — `Date` in TypeScript, `datetime`/`date` in Python, `time.Time`/`Date` in Go, `DateTimeImmutable` in PHP. | -| `mockData` | string | Data mode for the `mock` generator: `static` or `faker`. | -| `mockSeed` | number | Seed for `faker`-mode mocks. | -| `queryKeyPrefix` | string | Leading element for every `tanstack-query` query/mutation key — namespaces the cache when several generated APIs share one QueryClient. Config-only, no flag. | -| `codeSamples` | boolean | Emit `.code-samples.yaml` — an OpenAPI Overlay adding per-operation `x-codeSamples` collected from every selected generator that implements `sample()`. Config-only, no flag. | -| `serverUrl` | string | Server URL included in the client as its default; falls back to `servers[0].url`. | -| `goPackage` | string | Package clause for the `go` generator's output. Default `client`. | -| `binName` | string | Command name the `cli` generator uses in help output and to derive its credential environment variables. Defaults to the output stem, sanitized. | -| `options` | object | Per-generator options, keyed by generator name — validated against the schema a generator declares. See [Custom generators](../../guides/customize-client-generation.md#custom-generators). | -| `setup` | string | Path to a publisher setup module that gets included in the client — pre-configures defaults such as the server URL, retries, headers, and middleware. See [Publisher defaults](../../guides/customize-client-generation.md#publisher-defaults). | -| `pagination` | [Pagination object](#pagination-object) | Declares how the API paginates, so paginated operations gain typed `.pages()`/`.items()` async iterators. | +| Option | Type | Description | +| ---------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `generators` | [string] | Generators to run, in order. Each entry is a built-in name (`sdk`, `zod`, `tanstack-query` — or its `-vue`/`-svelte`/`-solid` variants — `swr`, `mock`, `transformers`, `cli`, `python`, `go`, `php`) or a custom generator's path or package name. | +| `outputMode` | string | File layout: `single` or `split`. TypeScript output only — the `python`, `go`, and `php` SDKs always emit one self-contained file. | +| `runtime` | string | Runtime distribution: `inline` or `package`. TypeScript output only — the `python`, `go`, and `php` SDKs always embed their runtime. | +| `importExt` | string | Extension in generated relative imports: `js` (default, for tsc and bundlers) or `ts` (for Node's built-in type stripping). TypeScript output only. | +| `argsStyle` | string | How operation inputs are passed: `flat` or `grouped`. 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 are throw-only (their language idiom IS the error mode) and reject `result`. | +| `dateType` | string | Type of `date`/`date-time` fields: `string` or `Date`. Every language honors it — `Date` in TypeScript, `datetime`/`date` in Python, `time.Time`/`Date` in Go, `DateTimeImmutable` in PHP. | +| `mockData` | string | Data mode for the `mock` generator: `static` or `faker`. | +| `mockSeed` | number | Seed for `faker`-mode mocks. | +| `queryKeyPrefix` | string | Leading element for every `tanstack-query` query/mutation key — namespaces the cache when several generated APIs share one QueryClient. Config-only, no flag. | +| `codeSamples` | boolean | Emit `.code-samples.yaml` — an OpenAPI Overlay adding per-operation `x-codeSamples` collected from every selected generator that implements `sample()`. Config-only, no flag. | +| `serverUrl` | string | Server URL included in the client as its default; falls back to `servers[0].url`. | +| `goPackage` | string | Package clause for the `go` generator's output. Must be a valid Go package name (lowercase letters, digits, and `_`, not starting with a digit, not a keyword) — an invalid value fails generation instead of emitting a file Go can't compile. Default `client`. | +| `binName` | string | Command name the `cli` generator uses in help output and to derive its credential environment variables. Defaults to the output stem, sanitized. | +| `options` | object | Per-generator options, keyed by generator name — validated against the schema a generator declares. See [Custom generators](../../guides/customize-client-generation.md#custom-generators). | +| `setup` | string | Path to a publisher setup module that gets included in the client — pre-configures defaults such as the server URL, retries, headers, and middleware. See [Publisher defaults](../../guides/customize-client-generation.md#publisher-defaults). | +| `pagination` | [Pagination object](#pagination-object) | Declares how the API paginates, so paginated operations gain typed `.pages()`/`.items()` async iterators. | ### Pagination object diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index 324b24a225..4b7a132274 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -126,6 +126,7 @@ The package root exports pure helpers over the API model that cover the cross-la | `docText(description)` | Description text 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 applying to an operation (per-op config > `x-redoclyPagination` > fitting convention), normalized. | +| `NotSupportedError` | Throw it to reject an option the generator can't honor — the CLI prints the message as a user error, not a crash. | These helpers plus `Printer` are the ONE way to author a generator, in any output language. Nothing in the authoring path depends on the `typescript` package, so a generator also runs in the browser or any other embedded host. diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index 447b1d7332..15ca07be0d 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -112,6 +112,7 @@ Only where the language leaves no choice: | Auth credentials | string or provider function | string or callable | string or callable | provider function only (no union types) | | Reserved-word fields | not applicable | trailing `_` (`type_`), wire name preserved | trailing `_`, wire name preserved | trailing `_` (`Type_`), `json` tag preserved | | File layout | `single` or `split` (`outputMode`) | one file | one file | one file | +| Namespacing | ES module — the file path | module name from the output stem | namespace from the API title | `package client`, or `goPackage` | | Runtime location | embedded or package (`runtime`) | embedded | embedded | embedded | `argsStyle` shapes TypeScript call sites; each language SDK follows its own idiom instead (keyword arguments, named arguments, a params struct). diff --git a/packages/cli/src/commands/generate-client.ts b/packages/cli/src/commands/generate-client.ts index 2f1e35e777..365ee0563f 100644 --- a/packages/cli/src/commands/generate-client.ts +++ b/packages/cli/src/commands/generate-client.ts @@ -23,6 +23,7 @@ export type GenerateClientCommandArgv = { runtime?: 'inline' | 'package'; 'import-ext'?: 'js' | 'ts'; 'bin-name'?: string; + 'go-package'?: string; 'args-style'?: 'flat' | 'grouped'; 'error-mode'?: 'throw' | 'result'; 'date-type'?: 'string' | 'Date'; @@ -79,6 +80,7 @@ export async function handleGenerateClient({ runtime: argv.runtime, importExt: argv['import-ext'], binName: argv['bin-name'], + goPackage: argv['go-package'], argsStyle: argv['args-style'], errorMode: argv['error-mode'], dateType: argv['date-type'], diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 1941d223d3..76aef54991 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -905,6 +905,12 @@ yargs(hideBin(process.argv)) type: 'string', requiresArg: true, }, + 'go-package': { + description: + "Package clause of the `go` generator's output (a valid Go package name). Defaults to `client`.", + type: 'string', + requiresArg: true, + }, 'import-ext': { describe: "Extension in generated relative imports: 'js' (default) suits tsc and bundlers; 'ts' suits runtimes that resolve specifiers literally, like Node's built-in type stripping (node client.ts).", diff --git a/packages/client-generator/eject-assets/AGENTS.md b/packages/client-generator/eject-assets/AGENTS.md index d2c385ac1e..710716426e 100644 --- a/packages/client-generator/eject-assets/AGENTS.md +++ b/packages/client-generator/eject-assets/AGENTS.md @@ -59,6 +59,8 @@ discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, | `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. | +| `NotSupportedError` | Throw it to reject an option the generator can't honor — the CLI prints the message as a user error, not a crash. | +| `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 diff --git a/packages/client-generator/src/authoring/index.ts b/packages/client-generator/src/authoring/index.ts index 397eb3e755..97b2aa2dae 100644 --- a/packages/client-generator/src/authoring/index.ts +++ b/packages/client-generator/src/authoring/index.ts @@ -2,6 +2,10 @@ // no typescript, no @redocly/openapi-core, no Node builtins — so it is exported // from the package ROOT: a custom generator importing only these stays TS-free. +// A generator rejects an option it can't honor by throwing this — the CLI prints its +// message as a user error instead of an unexpected crash. Part of the authoring surface +// because an ejected generator only imports from this barrel. +export { NotSupportedError } from '../errors.js'; export { Printer } from './printer.js'; export type { DateType } from './options.js'; export { casing, identifierFor, RESERVED_WORDS } from './naming.js'; @@ -32,4 +36,5 @@ export const AUTHORING_HELPER_NAMES = [ 'headerCoerceType', 'schemaAtPointer', 'paginationRuleFor', + 'NotSupportedError', ] as const; diff --git a/packages/client-generator/src/emitters/emit-options.ts b/packages/client-generator/src/emitters/emit-options.ts index 1e72ebb9ae..67ce7af277 100644 --- a/packages/client-generator/src/emitters/emit-options.ts +++ b/packages/client-generator/src/emitters/emit-options.ts @@ -64,6 +64,12 @@ export type EmitOptions = { * as a filename in a usage line. */ binName?: string; + /** + * Package clause of the `go` generator's output. Defaults to `client` — a generated + * file usually lands in a package the consumer already owns, so the name is theirs + * to choose. An invalid Go package name fails generation. + */ + goPackage?: string; /** * Auto-pagination rules (a convention rule + per-operation overrides + `exclude`), * resolved together with each operation's `x-redocly-pagination` extension. Verified diff --git a/packages/client-generator/src/generators/__tests__/go.test.ts b/packages/client-generator/src/generators/__tests__/go.test.ts index 981c2fa8f3..68637a4b22 100644 --- a/packages/client-generator/src/generators/__tests__/go.test.ts +++ b/packages/client-generator/src/generators/__tests__/go.test.ts @@ -546,6 +546,27 @@ describe('goGenerator parity features', () => { expect(files[0].content).toContain('config.ServerURL = "https://override.example"'); }); + it('honors goPackage for the package clause and rejects a name Go would not accept', () => { + const out = goGenerator({ + model: CAFE, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: { goPackage: 'rebilly' }, + })[0].content; + expect(out).toContain('package rebilly'); + expect(out).not.toContain('package client'); + expectGoCompiles(out); + + expect(() => + goGenerator({ + model: CAFE, + outputPath: '/out/client.ts', + outputMode: 'single', + emit: { goPackage: 'rebilly-core' }, + }) + ).toThrow(/goPackage "rebilly-core" is not a valid Go package name/); + }); + it('emits one URL function per declared server with variables as parameters', () => { const out = generateGo(); expect(out).toContain('func LiveServerURL(organizationId string) string {'); diff --git a/packages/client-generator/src/generators/go/AGENTS.md b/packages/client-generator/src/generators/go/AGENTS.md index 6922a8458d..562e69ba52 100644 --- a/packages/client-generator/src/generators/go/AGENTS.md +++ b/packages/client-generator/src/generators/go/AGENTS.md @@ -15,6 +15,11 @@ runtime. Go ≥ 1.21, standard library only — zero dependencies. - **Models are structs**: required fields by value, optionals as pointers with `,omitempty`; the `json` tag always carries the exact wire name. +- **Package clause:** `package client` by default, `goPackage` to override — a generated + file usually lands in a package the consumer already owns. The value is checked against + Go's own rule (lowercase letters, digits, `_`, no leading digit, not a keyword) and an + invalid one fails generation: silently rewriting a publisher's package name would be + worse than saying no. - **Naming:** exported PascalCase via `identifierFor` + an `N` prefix for digit-leading names (`3ds` → `N3ds` — an `_`-prefixed field is unexported and invisible to `encoding/json`); `+1`/`-1` become `Plus1`/`Minus1`. diff --git a/packages/client-generator/src/generators/go/index.ts b/packages/client-generator/src/generators/go/index.ts index a53b3bd17a..3d85c6e30d 100644 --- a/packages/client-generator/src/generators/go/index.ts +++ b/packages/client-generator/src/generators/go/index.ts @@ -14,6 +14,7 @@ import { headerCoerceType, identifierFor, isNullable, + NotSupportedError, paginationRuleFor, RESERVED_WORDS, schemaAtPointer, @@ -33,6 +34,20 @@ import type { CodeSample, Generator, SampleContext } from '../types.js'; const GO = RESERVED_WORDS.go; +/** + * The package clause the output declares. Rewriting an invalid name would hide the + * publisher's typo behind a package their imports don't mention, so this rejects it. + */ +function goPackageName(configured: string | undefined): string { + if (configured === undefined) return 'client'; + if (!/^[a-z_][a-z0-9_]*$/.test(configured) || GO.has(configured)) { + throw new NotSupportedError( + `goPackage "${configured}" is not a valid Go package name: use lowercase letters, digits, and underscores, don't start with a digit, and avoid Go keywords.` + ); + } + return configured; +} + /** An exported Go identifier (PascalCase; keywords can't collide since these start uppercase). */ function exported(name: string): string { const ident = identifierFor(name, { style: 'pascal', reserved: GO }); @@ -906,6 +921,7 @@ function writeGoServers(printer: Printer, model: ApiModel): void { export const goGenerator: Generator = ({ model, outputPath, emit }) => { const printer = new Printer('\t'); const dateType = emit.dateType ?? 'string'; + const packageName = goPackageName(emit.goPackage); const paginationRules = new Map(); for (const { op, ident } of goOperationIdents(model)) { const rule = paginationRuleFor(op, emit.pagination as Record | undefined); @@ -917,7 +933,7 @@ export const goGenerator: Generator = ({ model, outputPath, emit }) => { printer.line( '// Regenerate with `redocly generate-client`. Standard library only — zero dependencies.' ); - printer.line('package client'); + printer.line(`package ${packageName}`); printer.blank(); // One merged import block: the runtime uses every entry; generated code uses a subset. printer.block( diff --git a/packages/client-generator/src/pipeline.ts b/packages/client-generator/src/pipeline.ts index aaad1f9f6f..4c802ca271 100644 --- a/packages/client-generator/src/pipeline.ts +++ b/packages/client-generator/src/pipeline.ts @@ -187,6 +187,7 @@ export async function generateClient( runtime: options.runtime, importExt: options.importExt, binName: options.binName, + goPackage: options.goPackage, pagination: options.pagination, }; // Fail fast on an incompatible selection (missing prerequisite, unsupported diff --git a/packages/client-generator/src/types.ts b/packages/client-generator/src/types.ts index ff83a3bcf5..0c6a0c0780 100644 --- a/packages/client-generator/src/types.ts +++ b/packages/client-generator/src/types.ts @@ -90,6 +90,8 @@ export type GenerateClientOptions = { importExt?: 'js' | 'ts'; /** Command name for the `cli` generator; defaults to the output stem, sanitized. */ binName?: string; + /** Package clause of the `go` generator's output. Defaults to `client`. */ + goPackage?: string; /** * Emit `.code-samples.yaml` — an OpenAPI Overlay adding per-operation * `x-codeSamples` collected from every selected generator that implements `sample()`. 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 032f732d18..484ff88343 100644 --- a/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap +++ b/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap @@ -243,6 +243,9 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] }, "type": "array", }, + "goPackage": { + "type": "string", + }, "importExt": { "enum": [ "js", diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts index 6220393310..fc1b3a4cf0 100644 --- a/packages/core/src/types/redocly-yaml.ts +++ b/packages/core/src/types/redocly-yaml.ts @@ -375,6 +375,7 @@ const Client: NodeType = { runtime: { enum: ['inline', 'package'] }, importExt: { enum: ['js', 'ts'] }, binName: { type: 'string' }, + goPackage: { type: 'string' }, errorMode: { enum: ['throw', 'result'] }, dateType: { enum: ['string', 'Date'] }, mockData: { enum: ['static', 'faker'] }, diff --git a/tests/e2e/generate-client/go.test.ts b/tests/e2e/generate-client/go.test.ts index 96cd0600ff..d26d00adfd 100644 --- a/tests/e2e/generate-client/go.test.ts +++ b/tests/e2e/generate-client/go.test.ts @@ -1,5 +1,5 @@ import { spawnSync, type ChildProcess } from 'node:child_process'; -import { existsSync, rmSync } from 'node:fs'; +import { existsSync, readFileSync, rmSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -19,6 +19,7 @@ describe('generate-client go generator (end-to-end)', () => { afterAll(() => { rmSync(join(consumerDir, 'client'), { recursive: true, force: true }); rmSync(join(consumerDir, 'smoke'), { force: true }); + rmSync(join(consumerDir, 'renamed-package'), { recursive: true, force: true }); }); it('generates a self-contained client.go from the CLI', () => { @@ -26,6 +27,12 @@ describe('generate-client go generator (end-to-end)', () => { expect(existsSync(generatedFile)).toBe(true); }); + it('--go-package sets the package clause', () => { + const target = join(consumerDir, 'renamed-package'); + generate(fixture, join(target, 'client.ts'), ['--generator', 'go', '--go-package', 'rebilly']); + expect(readFileSync(join(target, 'client.go'), 'utf-8')).toContain('\npackage rebilly\n'); + }); + it.skipIf(!hasGo)( 'the generated client compiles (go build)', () => { From 28d8e0f95a3b9e7caeb3f4c7bf9f1ef1311932c7 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 5 Aug 2026 21:27:38 +0300 Subject: [PATCH 100/211] feat: let a generator declare its own options and validate them from config --- .changeset/agent-friendly-generators.md | 2 + .../@v2/guides/customize-client-generation.md | 11 ++ .../client-generator/eject-assets/AGENTS.md | 38 +++++- .../__tests__/generator-options.test.ts | 121 ++++++++++++++++++ .../src/generators/__tests__/resolve.test.ts | 10 ++ .../src/generators/options.ts | 96 ++++++++++++++ .../src/generators/resolve.ts | 1 + .../client-generator/src/generators/types.ts | 28 ++++ packages/client-generator/src/pipeline.ts | 6 + packages/client-generator/src/types.ts | 6 + .../__snapshots__/redocly-yaml.test.ts.snap | 9 ++ packages/core/src/types/redocly-yaml.ts | 9 ++ .../fixtures/route-map-plugin.mjs | 10 +- tests/e2e/generate-client/plugin.test.ts | 32 +++++ 14 files changed, 376 insertions(+), 3 deletions(-) create mode 100644 packages/client-generator/src/generators/__tests__/generator-options.test.ts create mode 100644 packages/client-generator/src/generators/options.ts diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index f8bcb6c118..c897a78bf2 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -8,3 +8,5 @@ Added agent-friendly client generation: built-in `python`, `go`, `php`, and `cli Selecting a generator now pulls in the generators it depends on: `--generator cli` emits the sdk and zod modules it needs (so the generated CLI validates requests by default and requires `zod` at run time), and `--generator tanstack-query` emits the sdk it wraps. Added `goPackage` (`--go-package`) to set the package clause of the `go` generator's output, and `--bin-name` as the flag form of `binName`. + +A custom generator can now declare its own options as a schema; publishers set them under `client.options.` and the values are validated — unknown key, wrong type, value outside an `enum`, missing required option — before anything is written, with defaults applied when `run` receives them. diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index 4b7a132274..5e42cd6b62 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -111,6 +111,17 @@ client: groupBy: path ``` +The schema covers what configuration needs, not all of JSON Schema: +a top-level `type: 'object'` with `properties`, `required`, and `additionalProperties`, +where each property is a scalar (`string`, `number`, `boolean`), an `enum`, or an array of scalars (`{ type: 'array', items: { type: 'string' } }`). +Each property may carry a `default` and a `description`. + +Validation runs once per generator before any file is written: +an unknown key, a value of the wrong type, a value outside an `enum`, or a missing `required` key fails generation with the generator's name and the offending key. +Unknown keys are rejected unless the schema sets `additionalProperties: true`. +`run` receives `options` with defaults applied, so a generator reads its options without re-checking them. +Setting `options` for a selected generator that declares no schema warns — the entry would otherwise be silently ignored. + ### Language-neutral helpers The package root exports pure helpers over the API model that cover the cross-language variance points, so a generator in any output language never re-implements schema semantics: diff --git a/packages/client-generator/eject-assets/AGENTS.md b/packages/client-generator/eject-assets/AGENTS.md index 710716426e..60411058ab 100644 --- a/packages/client-generator/eject-assets/AGENTS.md +++ b/packages/client-generator/eject-assets/AGENTS.md @@ -26,6 +26,43 @@ export default { }; ``` +## Declaring options + +A generator that needs configuration declares it as a schema; `run` then receives +`options` already validated, with defaults applied: + +```js +export default { + name: 'permissions-matrix', + options: { + type: 'object', + properties: { groupBy: { enum: ['tag', 'path'], default: 'tag' } }, + additionalProperties: false, + }, + run({ model, outputPath, options }) { + return [ + { path: outputPath.replace(/\.ts$/, '.permissions.md'), content: render(options.groupBy) }, + ]; + }, +}; +``` + +Users set them per generator name: + +```yaml +client: + generators: [sdk, ./generators/permissions-matrix.mjs] + options: + permissions-matrix: + groupBy: path +``` + +The supported subset is a top-level `type: 'object'` with `properties`, `required`, and +`additionalProperties`; each property is a scalar (`string`/`number`/`boolean`), an +`enum`, or an array of scalars, and may carry a `default` and a `description`. Don't +validate options inside `run` — an unknown key, a wrong type, a value outside an `enum`, +or a missing `required` key already fails generation before `run` is called. + Rules: output is deterministic (same description → same bytes); never add dependencies to the generated client; **never hand-edit generated output** — edit this generator and regenerate. Emitted file paths must stay inside the @@ -60,7 +97,6 @@ discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, | `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. | | `NotSupportedError` | Throw it to reject an option the generator can't honor — the CLI prints the message as a user error, not a crash. | -| `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 diff --git a/packages/client-generator/src/generators/__tests__/generator-options.test.ts b/packages/client-generator/src/generators/__tests__/generator-options.test.ts new file mode 100644 index 0000000000..c3aa0e0f07 --- /dev/null +++ b/packages/client-generator/src/generators/__tests__/generator-options.test.ts @@ -0,0 +1,121 @@ +import { logger } from '@redocly/openapi-core'; + +import type { ApiModel } from '../../intermediate-representation/model.js'; +import { runGenerators } from '../../pipeline.js'; +import { resolveGeneratorOptions } from '../options.js'; +import type { GeneratorDescriptor, GeneratorOptionsSchema } from '../types.js'; + +const MATRIX_SCHEMA: GeneratorOptionsSchema = { + type: 'object', + properties: { + groupBy: { enum: ['tag', 'path'], default: 'tag' }, + depth: { type: 'number' }, + include: { type: 'array', items: { type: 'string' } }, + title: { type: 'string' }, + }, + required: ['depth'], + additionalProperties: false, +}; + +function registryWith(descriptor: Partial) { + return new Map([ + ['permissions-matrix', { run: () => [], ...descriptor }], + ]); +} + +describe('resolveGeneratorOptions', () => { + const registry = registryWith({ options: MATRIX_SCHEMA }); + + it('applies declared defaults and passes valid values through', () => { + const resolved = resolveGeneratorOptions(['permissions-matrix'], registry, { + 'permissions-matrix': { depth: 2, include: ['orders'] }, + }); + expect(resolved.get('permissions-matrix')).toEqual({ + groupBy: 'tag', + depth: 2, + include: ['orders'], + }); + }); + + it('rejects an unknown key, a wrong type, a value outside an enum, and a missing required key', () => { + const reject = (options: Record) => () => + resolveGeneratorOptions(['permissions-matrix'], registry, { + 'permissions-matrix': options, + }); + + expect(reject({ depth: 1, groupby: 'tag' })).toThrow( + /"permissions-matrix".*unknown option "groupby".*groupBy, depth, include, title/s + ); + expect(reject({ depth: 'two' })).toThrow(/"depth" must be a number/); + expect(reject({ depth: 1, groupBy: 'paths' })).toThrow(/"groupBy" must be one of: tag, path/); + expect(reject({ depth: 1, include: ['orders', 7] })).toThrow( + /"include" must be an array of string/ + ); + expect(reject({})).toThrow(/requires the "depth" option/); + expect(reject([] as unknown as Record)).toThrow( + /options must be a map of option names to values/ + ); + }); + + it('keeps unknown keys when the schema allows them', () => { + const permissive = registryWith({ + options: { type: 'object', properties: {}, additionalProperties: true }, + }); + const resolved = resolveGeneratorOptions(['permissions-matrix'], permissive, { + 'permissions-matrix': { anything: 'goes' }, + }); + expect(resolved.get('permissions-matrix')).toEqual({ anything: 'goes' }); + }); + + it('warns when a selected generator that declares no options is configured', () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + try { + const resolved = resolveGeneratorOptions(['permissions-matrix'], registryWith({}), { + 'permissions-matrix': { groupBy: 'tag' }, + }); + expect(resolved.get('permissions-matrix')).toEqual({}); + expect(warn.mock.calls.join('\n')).toContain('declares no options'); + } finally { + warn.mockRestore(); + } + }); + + it('ignores options keyed to a generator this run did not select', () => { + expect(() => + resolveGeneratorOptions(['permissions-matrix'], registry, { + 'permissions-matrix': { depth: 1 }, + 'some-other-generator': { whatever: true }, + }) + ).not.toThrow(); + }); +}); + +describe('runGenerators', () => { + it('hands each generator its resolved options', () => { + let seen: unknown; + const registry = new Map([ + [ + 'permissions-matrix', + { + options: MATRIX_SCHEMA, + run: ({ options, outputPath }) => { + seen = options; + return [{ path: outputPath.replace(/\.ts$/, '.permissions.md'), content: '' }]; + }, + }, + ], + ]); + const generatorOptions = resolveGeneratorOptions(['permissions-matrix'], registry, { + 'permissions-matrix': { depth: 3 }, + }); + runGenerators({ title: 'T', version: '1', services: [], schemas: [] } as unknown as ApiModel, { + outputPath: '/out/client.ts', + outputMode: 'single', + emit: {}, + generators: ['permissions-matrix'], + registry, + generatorOptions, + }); + expect(seen).toEqual({ groupBy: 'tag', depth: 3 }); + }); +}); diff --git a/packages/client-generator/src/generators/__tests__/resolve.test.ts b/packages/client-generator/src/generators/__tests__/resolve.test.ts index 4798129c03..5dd2786d5b 100644 --- a/packages/client-generator/src/generators/__tests__/resolve.test.ts +++ b/packages/client-generator/src/generators/__tests__/resolve.test.ts @@ -17,6 +17,16 @@ describe('resolveGenerators', () => { expect(registry.has('zod')).toBe(true); }); + it("keeps a registered generator's declared options schema", async () => { + const custom: CustomGenerator = { + name: 'route-map', + run: noopRun, + options: { type: 'object', properties: { exportName: { type: 'string' } } }, + }; + const { registry } = await resolveGenerators(['route-map'], { customGenerators: [custom] }); + expect(registry.get('route-map')?.options).toEqual(custom.options); + }); + it('registers an inline custom generator and selects it by name', async () => { const custom: CustomGenerator = { name: 'route-map', run: noopRun }; const { selected, registry } = await resolveGenerators(['sdk', 'route-map'], { diff --git a/packages/client-generator/src/generators/options.ts b/packages/client-generator/src/generators/options.ts new file mode 100644 index 0000000000..1863b630bb --- /dev/null +++ b/packages/client-generator/src/generators/options.ts @@ -0,0 +1,96 @@ +// Per-generator options (`client.options.`), validated against the schema each +// generator declares. Validation runs once per run, before any file is written, so a +// typo in the config fails with the generator name and the offending key instead of +// reaching `run` — and a generator reads its options without re-checking them. + +import { isPlainObject, logger } from '@redocly/openapi-core'; + +import { NotSupportedError } from '../errors.js'; +import type { + GeneratorDescriptor, + GeneratorOptionSchema, + GeneratorOptionsSchema, +} from './types.js'; + +/** + * The validated options for every selected generator, keyed by name. Entries for + * generators this run didn't select are ignored: one config may serve several runs. + */ +export function resolveGeneratorOptions( + names: string[], + registry: Map | GeneratorDescriptor>, + configured: Record> | undefined +): Map> { + const resolved = new Map>(); + for (const name of names) { + const schema = registry.get(name)?.options; + const values = configured?.[name]; + if (schema === undefined) { + if (values !== undefined) { + logger.warn( + `generate-client: the "${name}" generator declares no options, so client.options.${name} is ignored.\n` + ); + } + resolved.set(name, {}); + continue; + } + resolved.set(name, validate(name, schema, values)); + } + return resolved; +} + +function validate( + name: string, + schema: GeneratorOptionsSchema, + values: Record | undefined +): Record { + if (values !== undefined && !isPlainObject(values)) { + throw new NotSupportedError( + `The "${name}" generator's options must be a map of option names to values.` + ); + } + const given = values ?? {}; + const declared = Object.keys(schema.properties); + if (schema.additionalProperties !== true) { + for (const key of Object.keys(given)) { + if (!declared.includes(key)) { + throw new NotSupportedError( + `The "${name}" generator got an unknown option "${key}". Declared options: ${declared.join(', ') || '(none)'}.` + ); + } + } + } + for (const key of schema.required ?? []) { + if (given[key] === undefined) { + throw new NotSupportedError(`The "${name}" generator requires the "${key}" option.`); + } + } + const result: Record = { ...given }; + for (const [key, property] of Object.entries(schema.properties)) { + if (result[key] === undefined) { + if (property.default !== undefined) result[key] = property.default; + continue; + } + const problem = describeMismatch(property, result[key]); + if (problem !== undefined) { + throw new NotSupportedError(`The "${name}" generator's "${key}" ${problem}.`); + } + } + return result; +} + +/** How a value fails its option schema, phrased to complete `"" …`; undefined when it fits. */ +function describeMismatch(property: GeneratorOptionSchema, value: unknown): string | undefined { + if ('enum' in property) { + return property.enum.includes(value as string | number | boolean) + ? undefined + : `must be one of: ${property.enum.join(', ')}`; + } + if (property.type === 'array') { + const itemType = property.items.type; + return Array.isArray(value) && value.every((item) => typeof item === itemType) + ? undefined + : `must be an array of ${itemType}`; + } + return typeof value === property.type ? undefined : `must be a ${property.type}`; +} diff --git a/packages/client-generator/src/generators/resolve.ts b/packages/client-generator/src/generators/resolve.ts index bef1e3e2b8..f88162115d 100644 --- a/packages/client-generator/src/generators/resolve.ts +++ b/packages/client-generator/src/generators/resolve.ts @@ -129,6 +129,7 @@ function register(registry: Map, custom: CustomGene registry.set(custom.name, { run: custom.run, sample: custom.sample, + options: custom.options, requires: custom.requires, errorModes: custom.errorModes, dateTypes: custom.dateTypes, diff --git a/packages/client-generator/src/generators/types.ts b/packages/client-generator/src/generators/types.ts index ccae85b17a..6dcc6ef1d4 100644 --- a/packages/client-generator/src/generators/types.ts +++ b/packages/client-generator/src/generators/types.ts @@ -32,6 +32,26 @@ export type GeneratorName = | 'go' | 'php'; +/** + * One option a generator accepts: a scalar, a closed set of values, or a list of scalars. + * Config values are scalars and lists of scalars, so the schema vocabulary stops there — + * nothing a `redocly.yaml` block can express is missing. + */ +export type GeneratorOptionSchema = { default?: unknown; description?: string } & ( + | { type: 'string' | 'number' | 'boolean' } + | { type: 'array'; items: { type: 'string' | 'number' | 'boolean' } } + | { enum: Array } +); + +/** The options a generator declares, as the JSON Schema subset the config layer validates. */ +export type GeneratorOptionsSchema = { + type: 'object'; + properties: Record; + required?: string[]; + /** Unknown keys are rejected unless this is `true` — a typo'd option is a config bug. */ + additionalProperties?: boolean; +}; + /** Everything a generator needs to produce its files. */ export type GeneratorInput = { model: ApiModel; @@ -43,6 +63,12 @@ export type GeneratorInput = { emit: EmitOptions; /** Every generator name in the run — lets a generator adapt to co-selection (cli wires zod validation when `zod` is selected). */ selected?: string[]; + /** + * This generator's own options from `client.options.`, already validated against + * the schema it declares with defaults applied — a generator reads them without re-checking. + * Empty when the generator declares no options. + */ + options?: Record; }; /** @@ -72,6 +98,8 @@ export type SampleContext = { model: ApiModel; emit: EmitOptions }; */ export type GeneratorDescriptor = { run: Generator; + /** The options this generator accepts, validated before `run` (see `GeneratorOptionsSchema`). */ + options?: GeneratorOptionsSchema; /** Optional: one idiomatic call snippet per operation for docs (`x-codeSamples`); * collected into an overlay when `codeSamples` is enabled. Return undefined to skip. */ sample?: (operation: OperationModel, ctx: SampleContext) => CodeSample | undefined; diff --git a/packages/client-generator/src/pipeline.ts b/packages/client-generator/src/pipeline.ts index 4c802ca271..19cc08d742 100644 --- a/packages/client-generator/src/pipeline.ts +++ b/packages/client-generator/src/pipeline.ts @@ -13,6 +13,7 @@ import { dirname, resolve, sep } from 'node:path'; import type { EmitOptions } from './emitters/emit-options.js'; import { NotSupportedError } from './errors.js'; import { validateSelection } from './generators/meta.js'; +import { resolveGeneratorOptions } from './generators/options.js'; import { resolveGenerators } from './generators/resolve.js'; import type { CodeSample, @@ -39,6 +40,8 @@ export function runGenerators( emit: EmitOptions; generators: string[]; registry: Map; + /** Per-generator options, already validated (see `resolveGeneratorOptions`). */ + generatorOptions?: Map>; } ): GeneratedFile[] { const files: GeneratedFile[] = []; @@ -56,6 +59,7 @@ export function runGenerators( outputMode: options.outputMode, emit: options.emit, selected: options.generators, + options: options.generatorOptions?.get(name) ?? {}, }); } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -194,11 +198,13 @@ export async function generateClient( // error-mode/date-type/runtime) before producing any file, and warn about options a // selected generator can't apply. validateSelection(selected, emit, registry, options.outputMode); + const generatorOptions = resolveGeneratorOptions(selected, registry, options.options); const files = runGenerators(model, { outputPath, outputMode: options.outputMode ?? 'single', emit, generators: selected, + generatorOptions, registry, }); diff --git a/packages/client-generator/src/types.ts b/packages/client-generator/src/types.ts index 0c6a0c0780..ab4fb29c66 100644 --- a/packages/client-generator/src/types.ts +++ b/packages/client-generator/src/types.ts @@ -92,6 +92,12 @@ export type GenerateClientOptions = { binName?: string; /** Package clause of the `go` generator's output. Defaults to `client`. */ goPackage?: string; + /** + * Per-generator options, keyed by generator name — validated against the schema the + * generator declares (`GeneratorOptionsSchema`) before it runs. Config-only, like + * `pagination`: a generator's option set is its own vocabulary, not a CLI flag. + */ + options?: Record>; /** * Emit `.code-samples.yaml` — an OpenAPI Overlay adding per-operation * `x-codeSamples` collected from every selected generator that implements `sample()`. 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 484ff88343..9ac884fa10 100644 --- a/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap +++ b/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap @@ -261,6 +261,11 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "mockSeed": { "type": "number", }, + "options": { + "additionalProperties": [Function], + "name": "ClientGeneratorOptionsMap", + "properties": {}, + }, "outputMode": { "enum": [ "single", @@ -285,6 +290,10 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] }, }, }, + "ClientGeneratorOptions": { + "additionalProperties": {}, + "properties": {}, + }, "ClientPagination": { "properties": { "cursorParam": { diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts index fc1b3a4cf0..8664dbe1c3 100644 --- a/packages/core/src/types/redocly-yaml.ts +++ b/packages/core/src/types/redocly-yaml.ts @@ -383,9 +383,17 @@ const Client: NodeType = { queryKeyPrefix: { type: 'string' }, codeSamples: { type: 'boolean' }, setup: { type: 'string' }, + options: mapOf('ClientGeneratorOptions'), pagination: 'ClientPagination', }, }; + +// Options a generator declares itself, so the vocabulary is the generator's, not ours; +// `generate-client` validates each block against the schema its generator declares. +const ClientGeneratorOptions: NodeType = { + properties: {}, + additionalProperties: {}, +}; const ClientPaginationRule: NodeType = { properties: { style: { enum: ['cursor', 'offset', 'page', 'link'] }, @@ -803,6 +811,7 @@ const CoreConfigTypes: Record = { ConfigGovernance, ConfigHTTP, Client, + ClientGeneratorOptions, ClientPagination, ClientPaginationRule, Where, diff --git a/tests/e2e/generate-client/fixtures/route-map-plugin.mjs b/tests/e2e/generate-client/fixtures/route-map-plugin.mjs index 937da80d82..202f141b3d 100644 --- a/tests/e2e/generate-client/fixtures/route-map-plugin.mjs +++ b/tests/e2e/generate-client/fixtures/route-map-plugin.mjs @@ -3,7 +3,13 @@ export default { name: 'route-map', requires: ['sdk'], - run({ model, outputPath }) { + // Declared options: the config block is validated against this before `run`. + options: { + type: 'object', + properties: { exportName: { type: 'string', default: 'routes' } }, + additionalProperties: false, + }, + run({ model, outputPath, options }) { const routes = model.services .flatMap((s) => s.operations) .map((op) => ` ${op.name}: '${op.method.toUpperCase()} ${op.path}',`) @@ -11,7 +17,7 @@ export default { return [ { path: outputPath.replace(/\.ts$/, '.routes.ts'), - content: `export const routes = {\n${routes}\n} as const;\n`, + content: `export const ${options.exportName} = {\n${routes}\n} as const;\n`, }, ]; }, diff --git a/tests/e2e/generate-client/plugin.test.ts b/tests/e2e/generate-client/plugin.test.ts index 4ff6a708ea..d5af0f6dcb 100644 --- a/tests/e2e/generate-client/plugin.test.ts +++ b/tests/e2e/generate-client/plugin.test.ts @@ -73,6 +73,38 @@ describe('generate-client custom generator (plugin) API', () => { rmSync(configDir, { recursive: true, force: true }); }, 60_000); + it("validates the generator's declared options from the config block and passes them to run", () => { + const dir = mkdtempSync(join(tmpdir(), 'ots-plugin-options-')); + cpSync(plugin, join(dir, 'route-map-plugin.mjs')); + const config = join(dir, 'redocly.yaml'); + const writeConfig = (options: string) => + writeFileSync( + config, + `extends: []\nclient:\n generators: [sdk, ./route-map-plugin.mjs]\n options:\n route-map:\n${options}` + ); + + writeConfig(' exportName: paths\n'); + const ok = spawnSync( + 'node', + [cliEntry, 'generate-client', cafe, '--output', join(dir, 'client.ts'), '--config', config], + { encoding: 'utf-8', cwd: dir } + ); + expect(ok.status, `${ok.stdout}\n${ok.stderr}`).toBe(0); + expect(readFileSync(join(dir, 'client.routes.ts'), 'utf-8')).toContain( + 'export const paths = {' + ); + + writeConfig(' exportname: paths\n'); + const typo = spawnSync( + 'node', + [cliEntry, 'generate-client', cafe, '--output', join(dir, 'client.ts'), '--config', config], + { encoding: 'utf-8', cwd: dir } + ); + expect(typo.status).not.toBe(0); + expect(`${typo.stdout}\n${typo.stderr}`).toMatch(/unknown option "exportname".*exportName/s); + rmSync(dir, { recursive: true, force: true }); + }, 60_000); + it('fails fast with an actionable message when a specifier cannot be loaded', () => { const dir = mkdtempSync(join(tmpdir(), 'ots-plugin-')); const { status, out } = run([ From 2270e1be68bef05e21d4aeb686445f70aa8b19d7 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 5 Aug 2026 21:32:06 +0300 Subject: [PATCH 101/211] docs: explain the shared naming vocabulary and why a language SDK is one file --- docs/@v2/guides/use-generated-client.md | 8 ++++++++ .../__tests__/sanitize-identifiers.test.ts | 4 +++- .../intermediate-representation/sanitize-identifiers.ts | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index 15ca07be0d..23d5d2508c 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -67,6 +67,10 @@ The CLI can also emit its own reference documentation as Markdown (every command `python`, `go`, and `php` emit a full SDK for that language — one self-contained file, no dependencies beyond the language's own HTTP support (`httpx` for Python; the standard library for Go; the curl extension for PHP). +One file is the deliverable, not a limitation we haven't gotten to: it can be downloaded from a docs page, committed, and read end to end, and it has no package to publish or import graph to wire up. +A description the size of a large public API produces a file of a few megabytes, which every one of these languages loads without trouble. +If you want a different layout, [eject the generator](../commands/eject-generator.md) — `run` returns the list of files, so splitting the output is a change to your own copy. + **They are the TypeScript client in another language.** Every capability is the same: typed models with `allOf` flattened, enums, discriminated unions decoded by their discriminator, one method per operation, auth, retries with `Retry-After` and jittered backoff, timeouts, idempotency keys, middleware, pagination iterators, SSE streaming, multipart bodies, binary downloads, typed response-header envelopes, and server-URL helpers for templated servers. Configuration is the same too: [`serverUrl`](../commands/generate-client.md), [`dateType`](../commands/generate-client.md), [`pagination`](../configuration/reference/client.md#pagination-object), and [`codeSamples`](../configuration/reference/client.md) all apply. @@ -180,6 +184,10 @@ api := client.New(client.Config{Middleware: []client.Middleware{{ A property or parameter whose name is a reserved word gets a trailing underscore, while the wire name is preserved — `tag.type_` in Python, `$tag->type_` in PHP, `tag.Type_` in Go, all serializing as `type`. The same applies to method arguments: `list_tags(type_=...)`, `ListTagsParams{Type_: ...}`. +Type and method **names** are resolved once, in the shared model, against a reserved set that is the union across the supported languages. +A schema therefore keeps the same name in every SDK you generate from the description — `Error` becomes `Error_2` in the Python SDK too, even though Python would accept `Error`, so an API's TypeScript, Python, PHP, and Go clients stay talkable-about with one vocabulary. +Every rename is reported with its cause, so a publisher who wants a different name renames the schema or operation in the description. + ## Package runtime By default the runtime is embedded in the generated file, so the client is self-contained. diff --git a/packages/client-generator/src/intermediate-representation/__tests__/sanitize-identifiers.test.ts b/packages/client-generator/src/intermediate-representation/__tests__/sanitize-identifiers.test.ts index 88911955e4..f5e19d397b 100644 --- a/packages/client-generator/src/intermediate-representation/__tests__/sanitize-identifiers.test.ts +++ b/packages/client-generator/src/intermediate-representation/__tests__/sanitize-identifiers.test.ts @@ -388,8 +388,10 @@ describe('rename warnings name the cause and the fix', () => { sanitizeIdentifiers(model([{ name: 'Error', schema: { kind: 'unknown' } }])) ); expect(messages).toContain('schema "Error"'); - expect(messages).toContain('a name the generated client already declares'); + expect(messages).toContain('a name a generated client already declares'); expect(messages).toContain('Error_2'); + // The reserved set is the union across languages, so the name is the same in every SDK. + expect(messages).toContain('spans every target language'); }); it('says a name was not a usable identifier when that is the actual cause', () => { diff --git a/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts b/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts index 6da75802dd..1b42abd20c 100644 --- a/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts +++ b/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts @@ -204,7 +204,7 @@ function warnRename(what: string, from: string, to: string, cause: RenameCause): : cause.kind === 'schema-collision' ? `${what} ${name} collides with the schema of the same name, so it is emitted as ${renamed} — rename the operation or the schema in the description to control its name` : cause.kind === 'reserved' - ? `${what} ${name} is a name the generated client already declares, so it is emitted as ${renamed}` + ? `${what} ${name} is a name a generated client already declares, so it is emitted as ${renamed} — the reserved set spans every target language, so one schema keeps one name across the SDKs you generate` : `${what} ${name} is not a usable identifier, so it is emitted as ${renamed}`; logger.warn(`generate-client: ${explanation}.\n`); } From 06334de5810a5dd47c15611504dae090e603c461 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 5 Aug 2026 21:36:34 +0300 Subject: [PATCH 102/211] feat!: rename the pagination extension to x-redoclyPagination --- .changeset/agent-friendly-generators.md | 2 ++ .../docs/adr/0018-auto-pagination.md | 2 +- .../authoring/__tests__/pagination.test.ts | 2 +- .../src/authoring/pagination.ts | 2 +- .../__tests__/client-assembly.test.ts | 6 ++--- .../src/emitters/__tests__/pagination.test.ts | 22 ++++++++----------- .../src/emitters/emit-options.ts | 2 +- .../src/emitters/pagination.ts | 12 +++++----- packages/client-generator/src/index.ts | 2 +- .../__tests__/build.test.ts | 10 ++++----- .../src/intermediate-representation/build.ts | 2 +- .../src/intermediate-representation/model.ts | 2 +- packages/client-generator/src/types.ts | 2 +- .../examples/pagination/src/main.ts | 2 +- tests/e2e/generate-client/fixtures/cli.yaml | 2 +- .../generate-client/fixtures/pagination.yaml | 2 +- .../pagination-consumer/index-offset.ts | 2 +- .../pagination-consumer/index.ts | 2 +- tests/e2e/generate-client/pagination.test.ts | 4 ++-- 19 files changed, 40 insertions(+), 42 deletions(-) diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index c897a78bf2..93bf267330 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -10,3 +10,5 @@ Selecting a generator now pulls in the generators it depends on: `--generator cl Added `goPackage` (`--go-package`) to set the package clause of the `go` generator's output, and `--bin-name` as the flag form of `binName`. A custom generator can now declare its own options as a schema; publishers set them under `client.options.` and the values are validated — unknown key, wrong type, value outside an `enum`, missing required option — before anything is written, with defaults applied when `run` receives them. + +**Note:** the per-operation pagination extension is now `x-redoclyPagination`, matching the camelCase of every other Redocly extension. Rename it in descriptions that declared `x-redocly-pagination`; the old spelling is no longer read. diff --git a/packages/client-generator/docs/adr/0018-auto-pagination.md b/packages/client-generator/docs/adr/0018-auto-pagination.md index 2ce3bebb3b..9f22cd4cd6 100644 --- a/packages/client-generator/docs/adr/0018-auto-pagination.md +++ b/packages/client-generator/docs/adr/0018-auto-pagination.md @@ -16,7 +16,7 @@ Additional forces: the descriptor contract is frozen ([ADR-0017](./0017-runtime- **Pagination is declared, then statically verified against the spec — never guessed.** -1. **Config-first declaration with a verified convention.** The `pagination` option (`redocly.yaml` `client.pagination`) carries one convention rule (`style: cursor | offset | page`, the style's advance param, `nextCursor`/`items` JSON pointers, optional `limitParam`) plus `operations` per-op overrides and an `exclude` list; the `x-redocly-pagination` operation extension takes the same rule fields inline in the spec. Precedence per operation: `operations[id]` > `x-redocly-pagination` > convention, with `exclude` killing all sources. +1. **Config-first declaration with a verified convention.** The `pagination` option (`redocly.yaml` `client.pagination`) carries one convention rule (`style: cursor | offset | page`, the style's advance param, `nextCursor`/`items` JSON pointers, optional `limitParam`) plus `operations` per-op overrides and an `exclude` list; the `x-redoclyPagination` operation extension takes the same rule fields inline in the spec. Precedence per operation: `operations[id]` > `x-redoclyPagination` > convention, with `exclude` killing all sources. The convention applies only to operations it **structurally fits** — the advance param is a declared query parameter whose schema accepts what the runtime sends (string-ish for `cursor`, numeric for `offset`/`page`), and the pointers resolve over the JSON success-response schema with `items` landing on an array. A convention misfit silently skips the operation; an **explicit** rule that doesn't fit — and a malformed rule from any source — fails generation with per-operation errors aggregated into one throw. No name sniffing, no shape guessing. 2. **Item typing is static, from the IR.** `emitters/pagination.ts` resolves the `items` pointer against the success-response `SchemaModel` (value-shape walking, `ref`s resolved through the model's named schemas) and writes the element type into the operation's `Ops` entry as `item`. `.items()` yields that type with zero runtime reflection; the same resolution is what verification rides on, so a type that emits is a pointer that resolves. 3. **Capability-seam placement.** The runtime logic is one module, `runtime/paginate.ts` (`pages`/`items` generators + RFC 6901 `resolvePointer`), wired through `Capabilities.paginate` exactly like SSE: the send core never statically imports it, inline output embeds it only when some descriptor paginates, and an unwired capability throws descriptively. The descriptor gains an optional `pagination` field (normalized: `style`, `param`, `nextCursor?`, `limitParam?`, `items`) — optional, so the frozen contract holds and non-paginated package-mode output stays byte-identical; `runtime: package` clients pick up pagination fixes via `npm update`. diff --git a/packages/client-generator/src/authoring/__tests__/pagination.test.ts b/packages/client-generator/src/authoring/__tests__/pagination.test.ts index 79141d4f10..b88f08129b 100644 --- a/packages/client-generator/src/authoring/__tests__/pagination.test.ts +++ b/packages/client-generator/src/authoring/__tests__/pagination.test.ts @@ -24,7 +24,7 @@ function op(extra: Partial = {}): OperationModel { const CURSOR = { style: 'cursor', cursorParam: 'after', nextCursor: '/next', items: '/items' }; describe('paginationRuleFor', () => { - it('per-operation config beats the x-redocly-pagination extension', () => { + it('per-operation config beats the x-redoclyPagination extension', () => { const operation = op({ paginationExtension: { ...CURSOR, items: '/fromExtension' } }); const rule = paginationRuleFor(operation, { operations: { listOrders: CURSOR } })!; expect(rule).toEqual({ diff --git a/packages/client-generator/src/authoring/pagination.ts b/packages/client-generator/src/authoring/pagination.ts index 40c954aac6..0b1ab8752c 100644 --- a/packages/client-generator/src/authoring/pagination.ts +++ b/packages/client-generator/src/authoring/pagination.ts @@ -1,5 +1,5 @@ // Language-neutral pagination-rule resolution: which rule applies to an operation -// (per-op config > the `x-redocly-pagination` extension > a fitting convention) and +// (per-op config > the `x-redoclyPagination` extension > a fitting convention) and // its normalized shape. Declaration-based — the TS toolkit's static fit VERIFICATION // (schema-level advance-param/pointer checks) remains generation-side; this helper is // what every language generator shares. 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 b2ea77313d..bc1b35b481 100644 --- a/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts +++ b/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts @@ -477,7 +477,7 @@ describe('emitClientSingleFile — pagination', () => { ); }); - it('resolves the x-redocly-pagination extension without any config', () => { + it('resolves the x-redoclyPagination extension without any config', () => { const model = modelWith([{ ...listOrders, paginationExtension: CURSOR_RULE }, getOrder], { schemas: [...SCHEMAS, ORDER_PAGE], }); @@ -534,9 +534,9 @@ describe('emitClientSingleFile — pagination', () => { ); expect(() => emitClientSingleFile(model)).toThrow( 'Invalid pagination configuration:\n' + - ' - Pagination for operation "listOrders" (x-redocly-pagination): ' + + ' - Pagination for operation "listOrders" (x-redoclyPagination): ' + 'query parameter "after" is not declared on the operation (declared: cursor, limit)\n' + - ' - Pagination for operation "listRefunds" (x-redocly-pagination): ' + + ' - Pagination for operation "listRefunds" (x-redoclyPagination): ' + 'the "items" pointer "/refunds" does not resolve in the success response schema' ); }); diff --git a/packages/client-generator/src/emitters/__tests__/pagination.test.ts b/packages/client-generator/src/emitters/__tests__/pagination.test.ts index 3cfa563b27..8475d40f89 100644 --- a/packages/client-generator/src/emitters/__tests__/pagination.test.ts +++ b/packages/client-generator/src/emitters/__tests__/pagination.test.ts @@ -314,7 +314,7 @@ describe('resolveOperationPagination — sources and precedence', () => { }); }); - it('applies the x-redocly-pagination extension when no per-op rule exists', () => { + it('applies the x-redoclyPagination extension when no per-op rule exists', () => { const op = listOrders({ paginationExtension: OFFSET_RULE }); const result = resolveOperationPagination(op, modelWith([op]), undefined); expect(result.spec).toEqual({ style: 'offset', param: 'offset', items: '/orders' }); @@ -431,14 +431,12 @@ describe('resolveOperationPagination — rule-shape validation (any source)', () '"limitParam" must be a query parameter name', ], ])( - 'rejects %s from the extension with the x-redocly-pagination source', + 'rejects %s from the extension with the x-redoclyPagination source', (_case, rule, problem) => { const op = listOrders({ paginationExtension: rule }); const { spec, error } = resolveOperationPagination(op, model(), undefined); expect(spec).toBeUndefined(); - expect(error).toBe( - `Pagination for operation "listOrders" (x-redocly-pagination): ${problem}` - ); + expect(error).toBe(`Pagination for operation "listOrders" (x-redoclyPagination): ${problem}`); } ); @@ -490,7 +488,7 @@ describe('resolveOperationPagination — fit verification', () => { const op = listOrders({ paginationExtension: rule }); const { spec, error } = resolveOperationPagination(op, modelWith([op]), undefined); expect(spec).toBeUndefined(); - expect(error).toBe(`Pagination for operation "listOrders" (x-redocly-pagination): ${problem}`); + expect(error).toBe(`Pagination for operation "listOrders" (x-redoclyPagination): ${problem}`); }); it('convention that does not fit resolves to nothing, silently', () => { @@ -512,7 +510,7 @@ describe('resolveOperationPagination — fit verification', () => { }); const { error } = resolveOperationPagination(op, modelWith([op]), undefined); expect(error).toBe( - 'Pagination for operation "listOrders" (x-redocly-pagination): ' + + 'Pagination for operation "listOrders" (x-redoclyPagination): ' + 'the operation has no JSON success response' ); const conventionOnly = listOrders({ @@ -530,7 +528,7 @@ describe('resolveOperationPagination — fit verification', () => { }); const { error } = resolveOperationPagination(sseOp, modelWith([sseOp]), undefined); expect(error).toBe( - 'Pagination for operation "listOrders" (x-redocly-pagination): ' + + 'Pagination for operation "listOrders" (x-redoclyPagination): ' + 'the operation is a Server-Sent Events stream' ); const conventionOnly = listOrders({ @@ -613,9 +611,7 @@ describe('resolveOperationPagination — fit verification', () => { const op = withParamSchema(name, schema, rule); const { spec, error } = resolveOperationPagination(op, modelWith([op]), undefined); expect(spec).toBeUndefined(); - expect(error).toBe( - `Pagination for operation "listOrders" (x-redocly-pagination): ${problem}` - ); + expect(error).toBe(`Pagination for operation "listOrders" (x-redoclyPagination): ${problem}`); }); it('convention with a misfitting advance param resolves to nothing, silently', () => { @@ -743,9 +739,9 @@ describe('resolveModelPagination', () => { }); expect(() => resolveModelPagination(modelWith([bad1, bad2]), undefined)).toThrow( 'Invalid pagination configuration:\n' + - ' - Pagination for operation "listOrders" (x-redocly-pagination): ' + + ' - Pagination for operation "listOrders" (x-redoclyPagination): ' + 'query parameter "after" is not declared on the operation (declared: cursor, offset, page, limit)\n' + - ' - Pagination for operation "listRefunds" (x-redocly-pagination): ' + + ' - Pagination for operation "listRefunds" (x-redoclyPagination): ' + '"style" must be one of "cursor" | "offset" | "page" | "link" (got "nope")' ); }); diff --git a/packages/client-generator/src/emitters/emit-options.ts b/packages/client-generator/src/emitters/emit-options.ts index 67ce7af277..38e7d93cb3 100644 --- a/packages/client-generator/src/emitters/emit-options.ts +++ b/packages/client-generator/src/emitters/emit-options.ts @@ -72,7 +72,7 @@ export type EmitOptions = { goPackage?: string; /** * Auto-pagination rules (a convention rule + per-operation overrides + `exclude`), - * resolved together with each operation's `x-redocly-pagination` extension. Verified + * resolved together with each operation's `x-redoclyPagination` extension. Verified * statically: an explicit rule that doesn't fit its operation fails generation. */ pagination?: PaginationConfig; diff --git a/packages/client-generator/src/emitters/pagination.ts b/packages/client-generator/src/emitters/pagination.ts index 37464a67b2..6d3e96c798 100644 --- a/packages/client-generator/src/emitters/pagination.ts +++ b/packages/client-generator/src/emitters/pagination.ts @@ -1,4 +1,4 @@ -// Auto-pagination resolution: turns config rules and `x-redocly-pagination` extensions into the +// Auto-pagination resolution: turns config rules and `x-redoclyPagination` extensions into the // normalized descriptor `PaginationSpec`, statically VERIFYING each rule fits its // operation (the advance param is a declared query param whose schema fits the style — // string-ish for `cursor`, a numeric scalar for `offset`/`page`; the JSON pointers @@ -22,7 +22,7 @@ import { isSseOp } from './sse.js'; export type PaginationStyle = 'cursor' | 'offset' | 'page' | 'link'; /** - * One user-facing pagination rule — the shared shape of the `x-redocly-pagination` operation + * One user-facing pagination rule — the shared shape of the `x-redoclyPagination` operation * extension and every `pagination` config rule. `nextCursor` and `items` are RFC 6901 * JSON pointers (starting with `/`) into the operation's success response. */ @@ -52,12 +52,12 @@ export type PaginationRule = { * The `pagination` config block: an optional convention rule (the top-level rule * fields, applied to every operation it structurally fits when `style` is set), plus * per-operation overrides and exclusions. Precedence per operation: - * `operations[id]` > the spec's `x-redocly-pagination` extension > the convention rule. + * `operations[id]` > the spec's `x-redoclyPagination` extension > the convention rule. */ export type PaginationConfig = Partial & { /** operationIds no source may paginate. */ exclude?: string[]; - /** Per-operation rules, keyed by operationId (beat `x-redocly-pagination` and the convention). */ + /** Per-operation rules, keyed by operationId (beat `x-redoclyPagination` and the convention). */ operations?: Record; }; @@ -73,7 +73,7 @@ export type ModelPagination = Map - * `x-redocly-pagination` > convention); `config.exclude` kills all of them. Returns the + * `x-redoclyPagination` > convention); `config.exclude` kills all of them. Returns the * normalized spec + the item element schema, `{}` when the operation doesn't paginate * (no source, or a convention that doesn't fit), or an `error` for a malformed rule * (any source) and for an explicit rule that doesn't fit the operation. @@ -90,7 +90,7 @@ export function resolveOperationPagination( return applyRule(op, model, perOp, `pagination.operations["${configName}"]`, true); } if (op.paginationExtension !== undefined) { - return applyRule(op, model, op.paginationExtension, 'x-redocly-pagination', true); + return applyRule(op, model, op.paginationExtension, 'x-redoclyPagination', true); } if (config?.style !== undefined) { const { exclude: _exclude, operations: _operations, ...convention } = config; diff --git a/packages/client-generator/src/index.ts b/packages/client-generator/src/index.ts index 3060b3dd4e..c59de736e3 100644 --- a/packages/client-generator/src/index.ts +++ b/packages/client-generator/src/index.ts @@ -53,7 +53,7 @@ export type { // The generated-CLI engine (package-mode cli files import it from the package root). export { runCli } from './runtime/cli.js'; export type { CliAuthScheme, CliCommand, CliWiring } from './runtime/cli.js'; -// The user-facing pagination rule shapes (`Config.pagination` / `x-redocly-pagination`). +// The user-facing pagination rule shapes (`Config.pagination` / `x-redoclyPagination`). export type { PaginationConfig, PaginationRule, PaginationStyle } from './emitters/pagination.js'; export type { GenerateClientConfig, diff --git a/packages/client-generator/src/intermediate-representation/__tests__/build.test.ts b/packages/client-generator/src/intermediate-representation/__tests__/build.test.ts index ff84090055..2d07782719 100644 --- a/packages/client-generator/src/intermediate-representation/__tests__/build.test.ts +++ b/packages/client-generator/src/intermediate-representation/__tests__/build.test.ts @@ -254,24 +254,24 @@ describe('buildOperation — tags', () => { }); }); -describe('buildOperation — x-redocly-pagination extension', () => { - it('captures the x-redocly-pagination value verbatim, without validation', () => { +describe('buildOperation — x-redoclyPagination extension', () => { + it('captures the x-redoclyPagination value verbatim, without validation', () => { const extension = { style: 'cursor', cursorParam: 'cursor', bogus: 42 }; const op = buildOpOnly({ paths: { '/orders': { - get: { operationId: 'listOrders', 'x-redocly-pagination': extension, responses: {} }, + get: { operationId: 'listOrders', 'x-redoclyPagination': extension, responses: {} }, } as never, }, }); expect(op.paginationExtension).toBe(extension); }); - it('captures a non-object x-redocly-pagination value too (validated by the emitter, not the IR)', () => { + it('captures a non-object x-redoclyPagination value too (validated by the emitter, not the IR)', () => { const op = buildOpOnly({ paths: { '/orders': { - get: { operationId: 'listOrders', 'x-redocly-pagination': 'nonsense', responses: {} }, + get: { operationId: 'listOrders', 'x-redoclyPagination': 'nonsense', responses: {} }, } as never, }, }); diff --git a/packages/client-generator/src/intermediate-representation/build.ts b/packages/client-generator/src/intermediate-representation/build.ts index b31d34c905..7c97f459f0 100644 --- a/packages/client-generator/src/intermediate-representation/build.ts +++ b/packages/client-generator/src/intermediate-representation/build.ts @@ -541,7 +541,7 @@ function buildOperation( // Extensions aren't in the @redocly operation type — read loosely, like `deprecated`. const paginationExtension = (operation as unknown as Record)[ - 'x-redocly-pagination' + 'x-redoclyPagination' ]; return { diff --git a/packages/client-generator/src/intermediate-representation/model.ts b/packages/client-generator/src/intermediate-representation/model.ts index 0a1379d7dc..b0c3e08a3f 100644 --- a/packages/client-generator/src/intermediate-representation/model.ts +++ b/packages/client-generator/src/intermediate-representation/model.ts @@ -223,7 +223,7 @@ export type OperationModel = { */ security: string[][]; /** - * The operation's `x-redocly-pagination` extension value, captured VERBATIM (spec + * The operation's `x-redoclyPagination` extension value, captured VERBATIM (spec * extensions are untyped). Validated by the pagination emitter, not the IR. */ paginationExtension?: unknown; diff --git a/packages/client-generator/src/types.ts b/packages/client-generator/src/types.ts index ab4fb29c66..b952009158 100644 --- a/packages/client-generator/src/types.ts +++ b/packages/client-generator/src/types.ts @@ -107,7 +107,7 @@ export type GenerateClientOptions = { /** * Auto-pagination rules: a convention rule (applied to every operation it * structurally fits), per-operation overrides, and `exclude`d operationIds — - * resolved together with each operation's `x-redocly-pagination` extension (per-op config > + * resolved together with each operation's `x-redoclyPagination` extension (per-op config > * extension > convention). Paginated operations gain typed `.pages()`/`.items()` * iterators. Verified statically: an explicit rule that doesn't fit its operation * fails generation. diff --git a/tests/e2e/generate-client/examples/pagination/src/main.ts b/tests/e2e/generate-client/examples/pagination/src/main.ts index 79a186ce54..43647fdd2d 100644 --- a/tests/e2e/generate-client/examples/pagination/src/main.ts +++ b/tests/e2e/generate-client/examples/pagination/src/main.ts @@ -5,7 +5,7 @@ // yield the array under `/orders`. The generator applies it only where it // STRUCTURALLY FITS — `listOrders` has the param and the pointers resolve, so it keeps // its one-shot call and gains `.pages()` / `.items()`; `getOrder` has no `cursor` -// param, so it stays a plain call. (Explicit declarations — `x-redocly-pagination` in the spec +// param, so it stays a plain call. (Explicit declarations — `x-redoclyPagination` in the spec // or per-operation config — that don't fit fail generation instead of being skipped.) import { configure, listOrders } from './api/client.js'; diff --git a/tests/e2e/generate-client/fixtures/cli.yaml b/tests/e2e/generate-client/fixtures/cli.yaml index 987a1190a8..57dfa543ae 100644 --- a/tests/e2e/generate-client/fixtures/cli.yaml +++ b/tests/e2e/generate-client/fixtures/cli.yaml @@ -12,7 +12,7 @@ paths: operationId: listOrders summary: List orders, one cursor page at a time. tags: [orders] - x-redocly-pagination: + x-redoclyPagination: style: cursor cursorParam: cursor nextCursor: /nextCursor diff --git a/tests/e2e/generate-client/fixtures/pagination.yaml b/tests/e2e/generate-client/fixtures/pagination.yaml index 04078216cc..817c33e56b 100644 --- a/tests/e2e/generate-client/fixtures/pagination.yaml +++ b/tests/e2e/generate-client/fixtures/pagination.yaml @@ -10,7 +10,7 @@ paths: operationId: listOrders summary: List orders, one cursor page at a time. # The extension arm: the pagination rule travels with the spec — no config needed. - x-redocly-pagination: + x-redoclyPagination: style: cursor cursorParam: cursor nextCursor: /nextCursor diff --git a/tests/e2e/generate-client/pagination-consumer/index-offset.ts b/tests/e2e/generate-client/pagination-consumer/index-offset.ts index 6a87be35e6..30f53281e7 100644 --- a/tests/e2e/generate-client/pagination-consumer/index-offset.ts +++ b/tests/e2e/generate-client/pagination-consumer/index-offset.ts @@ -15,7 +15,7 @@ async function main(): Promise { pageSizes.push(page.items.length); } - // Precedence, pinned at compile time: the spec's `x-redocly-pagination` (cursor) beats the + // Precedence, pinned at compile time: the spec's `x-redoclyPagination` (cursor) beats the // offset convention on `listOrders` — its descriptor keeps the extension's rule. const listOrdersStyle: 'cursor' = OPERATIONS.listOrders.pagination.style; diff --git a/tests/e2e/generate-client/pagination-consumer/index.ts b/tests/e2e/generate-client/pagination-consumer/index.ts index 6931903526..183c5dfbf3 100644 --- a/tests/e2e/generate-client/pagination-consumer/index.ts +++ b/tests/e2e/generate-client/pagination-consumer/index.ts @@ -1,6 +1,6 @@ import { listOrders } from './api.js'; -// The extension arm: `x-redocly-pagination` in the spec (no config) drives `listOrders`. +// The extension arm: `x-redoclyPagination` in the spec (no config) drives `listOrders`. // Exercises `.items()` across three cursor pages, `.pages()` page-level access, and // resume from a caller-provided cursor — while the caller's args are never mutated. async function main(): Promise { diff --git a/tests/e2e/generate-client/pagination.test.ts b/tests/e2e/generate-client/pagination.test.ts index c00922da45..021ff3bbac 100644 --- a/tests/e2e/generate-client/pagination.test.ts +++ b/tests/e2e/generate-client/pagination.test.ts @@ -5,7 +5,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { killServer, repoRoot, startServer } from './helpers.js'; -// Auto-pagination end to end, over a live server: the `x-redocly-pagination` extension arm +// Auto-pagination end to end, over a live server: the `x-redoclyPagination` extension arm // (cursor style — three pages, resume, abort) generated with NO config, the // config-convention arm (offset style, applied only where it structurally fits), and a // package-mode arm proving `.pages()`/`.items()` ship from the installed runtime. @@ -79,7 +79,7 @@ describe('generate-client pagination consumer', () => { test('generate all three arms and assert the emitted pagination surface', async () => { const generateClient = await loadGenerateClient(); - // Extension arm: NO pagination config — `x-redocly-pagination` alone drives `listOrders`. + // Extension arm: NO pagination config — `x-redoclyPagination` alone drives `listOrders`. await generateClient({ api: fixture, output: apiFile }); // Convention arm: an offset rule applied to every operation it structurally fits. await generateClient({ From b562a311f70573a9ba14e3b422e4fc2ae064ffff Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 5 Aug 2026 21:48:37 +0300 Subject: [PATCH 103/211] feat: eject the generator design as an agent skill instead of an AGENTS.md drop --- .changeset/agent-friendly-generators.md | 2 +- docs/@v2/commands/eject-generator.md | 4 +- packages/cli/src/commands/eject-generator.ts | 65 ++++++--- .../skills/client-generators/SKILL.md | 125 ++++++++++++++++++ .../eject-assets/skills/go-generator/SKILL.md | 77 +++++++++++ .../skills/php-generator/SKILL.md | 12 ++ .../skills/python-generator/SKILL.md | 70 ++++++++++ .../scripts/ejected-skill.mjs | 12 +- .../scripts/generate-eject-assets.mjs | 27 +++- .../__tests__/generator-skills.test.ts | 6 +- tests/e2e/generate-client/eject.test.ts | 25 ++-- tests/e2e/generate-client/examples.test.ts | 39 ++++-- .../.claude/skills/client-generators/SKILL.md | 125 ++++++++++++++++++ .../.claude/skills/php-generator/SKILL.md | 97 ++++++++++++++ .../examples/ejected-generator/README.md | 4 +- .../ejected-generator/generators/AGENTS.md | 86 +----------- 16 files changed, 643 insertions(+), 133 deletions(-) create mode 100644 packages/client-generator/eject-assets/skills/client-generators/SKILL.md create mode 100644 packages/client-generator/eject-assets/skills/go-generator/SKILL.md rename tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md => packages/client-generator/eject-assets/skills/php-generator/SKILL.md (87%) create mode 100644 packages/client-generator/eject-assets/skills/python-generator/SKILL.md create mode 100644 tests/e2e/generate-client/examples/ejected-generator/.claude/skills/client-generators/SKILL.md create mode 100644 tests/e2e/generate-client/examples/ejected-generator/.claude/skills/php-generator/SKILL.md diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index 93bf267330..e42713a2b5 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -3,7 +3,7 @@ '@redocly/cli': minor --- -Added agent-friendly client generation: built-in `python`, `go`, `php`, and `cli` generators, a language-neutral authoring toolkit, an `eject-generator` command that vendors any built-in generator into your repo together with its design as an agent skill, and verification against large real-world descriptions. +Added agent-friendly client generation: built-in `python`, `go`, `php`, and `cli` generators, a language-neutral authoring toolkit, an `eject-generator` command that vendors any built-in generator into your repo together with its design as an agent skill in `.claude/skills/`, and verification against large real-world descriptions. Selecting a generator now pulls in the generators it depends on: `--generator cli` emits the sdk and zod modules it needs (so the generated CLI validates requests by default and requires `zod` at run time), and `--generator tanstack-query` emits the sdk it wraps. diff --git a/docs/@v2/commands/eject-generator.md b/docs/@v2/commands/eject-generator.md index 27fdf16613..51db561d9d 100644 --- a/docs/@v2/commands/eject-generator.md +++ b/docs/@v2/commands/eject-generator.md @@ -33,7 +33,9 @@ Ejecting writes two things: - `.claude/skills/-generator/SKILL.md` — the generator's design as an agent skill: the decisions its code implements, and the loop to follow when changing it (state the change in the skill, then make the code match). Coding agents load skills automatically, so your agent starts from the design instead of reverse-engineering the code. -A first eject also drops `.claude/skills/client-generator-authoring/SKILL.md` — the shared authoring guide (the generator contract, the API model, the helper library). That file is refreshed on later ejects; anything you add outside its markers survives. +A first eject also drops `.claude/skills/client-generators/SKILL.md` — the shared authoring guide (the generator contract, the API model, the helper library). +Both skills are ours: they are rewritten on every eject and `--update`, so keep your own notes elsewhere. +Beside the code, `/AGENTS.md` gets a short pointer to the skills, so the directory explains itself to a reader who opens it cold; anything you add outside its markers survives. Eject wires itself up: it adds `@redocly/client-generator` to your `devDependencies` if it isn't there and points your config at the file, where a path entry takes over the built-in name. diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts index dd856d4d5d..90da23a56a 100644 --- a/packages/cli/src/commands/eject-generator.ts +++ b/packages/cli/src/commands/eject-generator.ts @@ -1,7 +1,7 @@ import { HandledError, logger } from '@redocly/openapi-core'; import { spawnSync } from 'node:child_process'; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { join, relative, resolve } from 'node:path'; +import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { ejectGeneratorTelemetry } from '../utils/generate-client-telemetry.js'; @@ -39,10 +39,38 @@ export function ejectAssetsDir(): string { return fileURLToPath(new URL('./eject-assets/', import.meta.url)); } -/** Drop or refresh `/AGENTS.md`: managed content between markers, user additions preserved. */ -function dropAgentsSkill(dir: string, assetsDir: string): void { - const template = readFileSync(join(assetsDir, 'AGENTS.md'), 'utf-8').trim(); - const managed = `${AGENTS_BEGIN}\n\n${template}\n\n${AGENTS_END}\n`; +/** Copy a shipped skill into the repo's `.claude/skills//SKILL.md`, overwriting ours. */ +function dropSkill(skill: string, assetsDir: string): string { + const target = join(process.cwd(), '.claude', 'skills', skill, 'SKILL.md'); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync( + target, + readFileSync(join(assetsDir, 'skills', skill, 'SKILL.md'), 'utf-8'), + 'utf-8' + ); + return relative(process.cwd(), target); +} + +/** + * Drop or refresh the pointer at `/AGENTS.md`: it says what these files are and + * where their design lives, so the directory explains itself to an agent that opens it + * without the skills loaded. Managed between markers; anything the user adds is kept. + */ +function dropPointer(dir: string, ejected: string[]): void { + const lines = [ + '# Ejected client generators', + '', + 'These files are Redocly client generators you own; `redocly generate-client` runs them.', + 'Their design and the authoring toolkit are agent skills — edit the skill first, then make', + 'the code match, and never hand-edit generated client output:', + '', + '- `.claude/skills/client-generators/SKILL.md` — the API model, the helpers, the loop.', + ...ejected.map( + (name) => + `- \`.claude/skills/${name}-generator/SKILL.md\` — the \`${name}\` generator's design.` + ), + ]; + const managed = `${AGENTS_BEGIN}\n\n${lines.join('\n')}\n\n${AGENTS_END}\n`; const target = join(dir, 'AGENTS.md'); if (!existsSync(target)) { writeFileSync(target, managed, 'utf-8'); @@ -64,16 +92,6 @@ function dropAgentsSkill(dir: string, assetsDir: string): void { ); } -/** The generator's OWN design skill, refreshed on every eject/update (it documents OUR - * generator; user notes belong outside it). Dropped as `generators/.AGENTS.md`. */ -function dropGeneratorSkill(dir: string, assetsDir: string, name: string): void { - writeFileSync( - join(dir, `${name}.AGENTS.md`), - readFileSync(join(assetsDir, 'generators', `${name}.AGENTS.md`), 'utf-8'), - 'utf-8' - ); -} - /** 3-way merge via `git merge-file`; returns the merged text and the conflict count. */ function threeWayMerge( customized: string, @@ -117,6 +135,11 @@ function threeWayMerge( return { merged: result.stdout, conflicts: result.status }; } +/** 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`))); +} + export const handleEjectGenerator = async ({ argv }: CommandArgs) => { const name = argv.generator ?? ''; // Coarse usage telemetry: our command action, an ALLOWLISTED built-in name, and the @@ -166,8 +189,9 @@ export const handleEjectGenerator = async ({ argv }: CommandArgs 0 ? 'conflicts' : 'success'; if (conflicts > 0) { ejectGeneratorTelemetry.eject_generator_conflicts = conflicts; @@ -189,8 +213,9 @@ export const handleEjectGenerator = async ({ argv }: CommandArgs GeneratedFile[]`. It receives the +language-agnostic API model and returns files — in ANY output language. It runs +in the same pass as the built-ins; select it by path in `redocly.yaml`: + +```yaml +client: + generators: [sdk, ./generators/my-generator.mjs] +``` + +## The contract + +```js +/** @type {import('@redocly/client-generator').CustomGenerator} */ +export default { + name: 'my-generator', + run({ model, outputPath, outputMode, emit }) { + return [{ path: outputPath.replace(/\.ts$/, '.mine.txt'), content: '…' }]; + }, + // Optional: one idiomatic call snippet per operation for docs (x-codeSamples), + // collected into an overlay file when `client.codeSamples: true` is set. + sample(operation, { model, emit }) { + return { lang: 'python', source: '…' }; + }, +}; +``` + +## Declaring options + +A generator that needs configuration declares it as a schema; `run` then receives +`options` already validated, with defaults applied: + +```js +export default { + name: 'permissions-matrix', + options: { + type: 'object', + properties: { groupBy: { enum: ['tag', 'path'], default: 'tag' } }, + additionalProperties: false, + }, + run({ model, outputPath, options }) { + return [ + { path: outputPath.replace(/\.ts$/, '.permissions.md'), content: render(options.groupBy) }, + ]; + }, +}; +``` + +Users set them per generator name: + +```yaml +client: + generators: [sdk, ./generators/permissions-matrix.mjs] + options: + permissions-matrix: + groupBy: path +``` + +The supported subset is a top-level `type: 'object'` with `properties`, `required`, and +`additionalProperties`; each property is a scalar (`string`/`number`/`boolean`), an +`enum`, or an array of scalars, and may carry a `default` and a `description`. Don't +validate options inside `run` — an unknown key, a wrong type, a value outside an `enum`, +or a missing `required` key already fails generation before `run` is called. + +Rules: output is deterministic (same description → same bytes); never add +dependencies to the generated client; **never hand-edit generated output** — +edit this generator and regenerate. Emitted file paths must stay inside the +`--output` directory (subdirectories are fine) — escapes are rejected. +Optionally declare `contract` (the `GENERATOR_CONTRACT` number exported by +`@redocly/client-generator`): a CLI whose contract differs then fails with the +fix path instead of feeding your generator an unexpected model shape. Ejected +generators carry it automatically. + +## The model (IR) + +`model.services[].operations[]` — each operation carries `name`, `specName`, +`method`, `path`, `tags`, `pathParams`/`queryParams`/`headerParams`/`cookieParams`, +`requestBody`, `successResponses`/`errorResponses` (each with a `schema`), and +`security`. `model.schemas` holds the named schemas. Every schema is a +discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, +`literal`, `enum`, `union` (optionally with a discriminator), `intersection` +(allOf), `null`, `unknown`, `omit`. + +## Helpers (import from '@redocly/client-generator') + +| Helper | Use | +| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `flattenAllOf(schema, model)` | The merged property view of allOf compositions — languages without intersection types render this. | +| `discriminatorCases(schema, model)` | `{ property, cases }` dispatch table for discriminated unions. | +| `isNullable(schema)` / `unwrapNullable(schema)` | Detect and strip `null` union members (`Optional[T]`, pointers, `Option`). | +| `enumValues(schema)` | Values plus SCREAMING_SNAKE member-name suggestions. | +| `headerCoerceType(schema, model)` | Response-header coerce hint (`integer`/`number`/`boolean`/`string`) through refs, nullables, and allOf wrappers. | +| `casing` / `identifierFor(name, { style, reserved })` | camel/pascal/snake/screaming; keyword-safe identifiers (`RESERVED_WORDS.python/go/typescript` shipped). | +| `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. | +| `NotSupportedError` | Throw it to reject an option the generator can't honor — the CLI prints the message as a user error, not a crash. | +| `AUTHORING_HELPER_NAMES` | The list of the above (introspection). | + +Worked example: the built-in `python` generator +(`packages/client-generator/src/generators/python/index.ts` in the Redocly CLI repo) is +authored with exactly this toolkit and nothing else — models via `flattenAllOf`/ +`enumValues`/`discriminatorCases`, all code through `Printer`, every name through +`identifierFor(..., RESERVED_WORDS.python)`. + +TypeScript-emitting generators may additionally use the TS toolkit from +`@redocly/client-generator/generate` (`ts`, `printStatements`, `schemaToTypeNode`, …). + +## The loop + +1. Edit the generator. +2. Run `redocly generate-client`. +3. Inspect `git diff` of the generated output. +4. Repeat. Generated files are never hand-edited. + +If you had to work around a **missing helper** or a wrong default, tell the user +and suggest filing an issue at https://github.com/Redocly/redocly-cli/issues — +include the generator snippet and the helper you expected to exist. diff --git a/packages/client-generator/eject-assets/skills/go-generator/SKILL.md b/packages/client-generator/eject-assets/skills/go-generator/SKILL.md new file mode 100644 index 0000000000..718c19c408 --- /dev/null +++ b/packages/client-generator/eject-assets/skills/go-generator/SKILL.md @@ -0,0 +1,77 @@ +--- +name: go-generator +description: Design of the ejected Redocly `go` client generator. Read it, and update it, before changing generators/go.mjs. +--- + +# The `go` generator — its skill + +This file is the DESIGN of your ejected `go` generator (`generators/go.mjs`): +**to change the generator, edit this skill first, then make the code match it** — a diff +to `generators/go.mjs` that has no covering sentence here is incomplete. + +## What it emits + +One self-contained `.go` (`package client`): structs with `json` tags, a `Client` +with one `(T, error)` method per operation taking a `context.Context`, and the embedded +runtime. Go ≥ 1.21, standard library only — zero dependencies. + +## Design decisions that must hold + +- **Models are structs**: required fields by value, optionals as pointers with + `,omitempty`; the `json` tag always carries the exact wire name. +- **Package clause:** `package client` by default, `goPackage` to override — a generated + file usually lands in a package the consumer already owns. The value is checked against + Go's own rule (lowercase letters, digits, `_`, no leading digit, not a keyword) and an + invalid one fails generation: silently rewriting a publisher's package name would be + worse than saying no. +- **Naming:** exported PascalCase via `identifierFor` + an `N` prefix for digit-leading + names (`3ds` → `N3ds` — an `_`-prefixed field is unexported and invisible to + `encoding/json`); `+1`/`-1` become `Plus1`/`Minus1`. +- **Enums** are typed consts (`type Status string` + `StatusInProgress Status = …`); + **discriminated unions** are `type X = any` plus a generated `UnmarshalX([]byte)` + dispatcher; **allOf** is flattened. +- **Errors:** `(T, error)` returns ARE the error mode — `errorMode` does not change the + output (the generator declares `errorModes: ['throw']`, so `result` fails fast). + Non-2xx → `*APIError`; timeouts → `*TimeoutError`. +- **Dates:** `dateType: Date` maps `format: date-time` to `time.Time` (encoding/json + handles RFC 3339 natively) and `date` to the runtime's `Date` wrapper, which + marshals as `2006-01-02`. Query values format explicitly, never via `String()`. +- **Response headers:** an operation that DECLARES success-response headers gains a + `WithHeaders(ctx, …) (T, Headers, error)` variant; `Headers` is a + generated struct with pointer fields (nil when absent or unparsable), coerced to + int64/bool/string. Operations without declared headers get no variant, and the + base method stays `(T, error)`. +- **Servers:** when the description declares servers, one `URL(...)` function per + server is emitted (named from the server description); server VARIABLES become string + parameters (Go has no defaults — the doc comment states the spec default), so templated + base URLs need no manual string building. The client's baked default stays `servers[0]` + with variable defaults substituted. +- **Parity surface:** auth, retries with `Retry-After` + jittered backoff, per-attempt + `context.WithTimeout`, idempotency keys, middleware, pagination (`Pages`/`Items` + as `func(yield func(T, error) bool)` — `range`-over-func needs Go ≥ 1.23; 1.21 calls + them with a callback), SSE, multipart. +- **The EMITTED FILE is gofmt-clean, not just the runtime.** `gofmt -l` on generated + output must print nothing, so the download is idiomatic as-is. The emitter earns that + deterministically, without shelling out to `gofmt`: + - `alignGoColumns` pads columns the way gofmt's tabwriter does — struct field types and + tags, `const`/`var` types and `=`, and map-literal values — within each contiguous run. + A line starting with a Go KEYWORD is a statement, never a declaration, and must never + be padded (`case "x":` is not a field). + - `case` sits at its `switch`'s own indent, so the switch body is not emitted as an + indented block. + - At most one blank line between declarations, none at end of file, and a blank line + inside a doc comment is `//` — never `// ` with a trailing space. + A change here is verified by the `gofmt -l` bar in the unit suite, at cafe AND + large-description scale. +- The runtime is hand-written in `go-runtime/runtime.go` (gofmt-clean, `go vet`-clean) + and embedded at prepare time. +- Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Make `generators/go.mjs` match it. +3. Run `redocly generate-client` and inspect the `git diff` of the generated output — + generated files are never hand-edited. + +Newer built-in versions merge in with `redocly eject-generator go --update`. diff --git a/tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md b/packages/client-generator/eject-assets/skills/php-generator/SKILL.md similarity index 87% rename from tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md rename to packages/client-generator/eject-assets/skills/php-generator/SKILL.md index 5c8b813304..c330d99da5 100644 --- a/tests/e2e/generate-client/examples/ejected-generator/generators/php.AGENTS.md +++ b/packages/client-generator/eject-assets/skills/php-generator/SKILL.md @@ -1,3 +1,8 @@ +--- +name: php-generator +description: Design of the ejected Redocly `php` client generator. Read it, and update it, before changing generators/php.mjs. +--- + # The `php` generator — its skill This file is the DESIGN of your ejected `php` generator (`generators/php.mjs`): @@ -28,6 +33,13 @@ extension — zero Composer dependencies. The namespace derives from the API tit - **Enums** are native backed enums (string/int); other scalars stay aliases. **Discriminated unions** are `match`-based `unmarshalX(array $data)` dispatchers; **allOf** is flattened. +- **Unions keep their types where PHP 8.1 can express them.** A union of scalars, enums, + classes, or arrays becomes a native union type (`int|string`, `PromotionType|array`) + rather than collapsing to `mixed` — rich list filters are the common case and losing + their types loses the point of a typed SDK. It falls back to `mixed` only when a member + has no PHP type of its own (an inline object, an intersection, `unknown`), because + `mixed` cannot appear inside a union. Nullability is expressed as `|null` in a union + (PHP forbids mixing `?` with `|`) and `?T` for a single type. - **Errors:** exceptions ARE the error mode (`ApiError`/`TimeoutError` extend `\RuntimeException`); `errorMode` does not change the output (the generator declares `errorModes: ['throw']`, so `result` fails fast). diff --git a/packages/client-generator/eject-assets/skills/python-generator/SKILL.md b/packages/client-generator/eject-assets/skills/python-generator/SKILL.md new file mode 100644 index 0000000000..8b6993cc5a --- /dev/null +++ b/packages/client-generator/eject-assets/skills/python-generator/SKILL.md @@ -0,0 +1,70 @@ +--- +name: python-generator +description: Design of the ejected Redocly `python` client generator. Read it, and update it, before changing generators/python.mjs. +--- + +# The `python` generator — its skill + +This file is the DESIGN of your ejected `python` generator (`generators/python.mjs`): +**to change the generator, edit this skill first, then make the code match it** — a diff +to `generators/python.mjs` that has no covering sentence here is incomplete. + +## What it emits + +One self-contained `.py`: typed dataclass models, a sync `Client` and an async +`AsyncClient`, and the embedded runtime. Python ≥ 3.9; the only dependency is +[httpx](https://www.python-httpx.org/) (`pip install httpx`). + +## Design decisions that must hold + +- **The file name is an importable module name.** The `--output` stem follows the TypeScript + convention (`openapi.client.ts`), and `openapi.client.py` cannot be imported by name — nor + can hyphens or a leading digit. The stem is converted with + `identifierFor(stem, snake)`, so `rebilly-core.client.ts` emits + `rebilly_core_client.py` and `import rebilly_core_client` just works. + +- **Models are dataclasses**, required fields first (a dataclass constraint), optionals + `Optional[T] = None`. Wire names live in a `_field_map: ClassVar[Dict[str, str]]`; + decode/encode is reflective (`_decode.py`, `get_type_hints`) — no per-model codecs. +- **Naming:** fields/methods snake*case via `identifierFor(..., RESERVED_WORDS.python)`; + reserved words get a trailing underscore (`class*`); `+1`/`-1`become`plus_1`/`minus_1`. +- **Enums** are `class X(str, Enum)` with SCREAMING members; **unions** are `Union[...]` + aliases. A DISCRIMINATED union registers its dispatch table in the runtime's + `DISCRIMINATORS` registry (`DISCRIMINATORS[Pet] = ("petType", {"cat": Cat, ...})`), + and `decode()` routes through it — `isinstance` narrowing works on decoded members. + Undiscriminated unions decode by trying each member in order (the first that + hydrates wins — see `_decode.py`). **allOf** is flattened via `flattenAllOf`. +- **Auth keys match the other languages.** `auth={"apiKey": {...}}` is the documented key — + the same spelling TypeScript and PHP use, and the same as the scheme kind — with + `api_key` accepted as an alias so a snake_case config keeps working. +- **Errors:** `errorMode` maps to raising `ApiError` (default) or returning a `Result` + dataclass — the only generator with both modes outside TypeScript. +- **Dates:** `dateType: Date` annotates `format: date-time` as `datetime` and `date` as + `date`; `_decode.py` parses ISO strings into them and `encode()` writes `isoformat()` + back. The default (`string`) keeps the wire shape. +- **Response headers:** an operation that DECLARES success-response headers gains a + `_with_headers()` variant (sync and async) returning `Envelope[T]` — `data`, + `headers` (coerced to int/bool/str with snake_case keys; absent/unparsable values + omitted), and the raw `response`. Operations without declared headers get no + variant, and the base method stays body-only. +- **Servers:** when the description declares servers, a `Servers` class is emitted with + one static method per server; server VARIABLES become keyword arguments defaulting to + the spec's defaults (`Servers.production(organization_id="org_x")`), so templated base + URLs need no manual string building. The client's baked default stays `servers[0]` + with variable defaults substituted. +- **Parity surface:** auth (bearer/basic/apiKey), retries with `Retry-After` + jittered + backoff, timeouts, idempotency keys, middleware, pagination (`_pages()` / + `_items()` + `aiter` mirrors), SSE (`iter_sse`/`aiter_sse`), multipart. +- The runtime is hand-written in `python-runtime/*.py` and embedded as strings at prepare + time — generator code never builds runtime logic from templates. +- Authored ONLY with the neutral toolkit (`Printer`, naming, schema, pagination helpers) — + the dogfooding guard fails otherwise. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Make `generators/python.mjs` match it. +3. Run `redocly generate-client` and inspect the `git diff` of the generated output — + generated files are never hand-edited. + +Newer built-in versions merge in with `redocly eject-generator python --update`. diff --git a/packages/client-generator/scripts/ejected-skill.mjs b/packages/client-generator/scripts/ejected-skill.mjs index 33938d7cd5..070e94842a 100644 --- a/packages/client-generator/scripts/ejected-skill.mjs +++ b/packages/client-generator/scripts/ejected-skill.mjs @@ -1,4 +1,5 @@ -// The source skill speaks to development inside this repo — its intro and modify +// The prepare-time transform from a generator's in-repo skill to the SKILL.md eject drops +// into the user's `.claude/skills/`. The source skill speaks to development inside this repo — its intro and modify // loop reference index.ts, the prepare script, and our vitest suites, none of which // exist in a user's repo. The ejected copy keeps the design sections verbatim but // rewrites those two parts for the user's world: their file is generators/.mjs @@ -6,6 +7,13 @@ // unchanged, and both anchors are structural (the first `## ` heading and the final // `## The modify loop` section), so skills can grow without touching this transform. export function ejectedSkill(source, name) { + const frontmatter = [ + '---', + `name: ${name}-generator`, + `description: Design of the ejected Redocly \`${name}\` client generator. Read it, and update it, before changing generators/${name}.mjs.`, + '---', + '', + ].join('\n'); const titleEnd = source.indexOf('\n\n'); const firstHeading = source.indexOf('\n## '); const loopHeading = source.indexOf('\n## The modify loop'); @@ -29,5 +37,5 @@ export function ejectedSkill(source, name) { '', ].join('\n'); const designSections = source.slice(firstHeading, loopHeading); - return `${source.slice(0, titleEnd)}\n\n${intro}\n${designSections}\n${modifyLoop}`; + return `${frontmatter}\n${source.slice(0, titleEnd)}\n\n${intro}\n${designSections}\n${modifyLoop}`; } diff --git a/packages/client-generator/scripts/generate-eject-assets.mjs b/packages/client-generator/scripts/generate-eject-assets.mjs index 6d5eb4eaa4..040b0cc2ef 100644 --- a/packages/client-generator/scripts/generate-eject-assets.mjs +++ b/packages/client-generator/scripts/generate-eject-assets.mjs @@ -23,8 +23,25 @@ if (contractMatch === null) { } const contract = Number(contractMatch[1]); const outDir = join(pkgRoot, 'eject-assets', 'generators'); +const skillsDir = join(pkgRoot, 'eject-assets', 'skills'); mkdirSync(outDir, { recursive: true }); +// The shared authoring skill ships as a skill too, so an agent in the user's repo loads +// it without being told to read a file. +mkdirSync(join(skillsDir, 'client-generators'), { recursive: true }); +writeFileSync( + join(skillsDir, 'client-generators', 'SKILL.md'), + [ + '---', + 'name: client-generators', + 'description: Write or change a Redocly client generator — the API model, the language-neutral helper toolkit, and the edit → regenerate → diff loop.', + '---', + '', + readFileSync(join(pkgRoot, 'eject-assets', 'AGENTS.md'), 'utf-8').trim(), + '', + ].join('\n') +); + const EJECTABLE = [ { name: 'python', run: 'pythonGenerator', sample: 'pythonSample' }, { name: 'go', run: 'goGenerator', sample: 'goSample' }, @@ -60,10 +77,10 @@ for (const { name, run, sample } of EJECTABLE) { process.stderr.write(`eject asset ${name}.mjs failed node --check:\n${check.stderr}`); process.exit(1); } - // The generator's OWN skill ships beside its code: eject drops it as - // `generators/.AGENTS.md` so the agent that edits the ejected file - // starts from the generator's design, not from reverse-engineering it. - // The intro and modify loop are rewritten for the user's repo on the way. + // The generator's OWN design ships as `.claude/skills/-generator/SKILL.md`, so the + // agent that edits the ejected file starts from the design instead of reverse-engineering + // it. The intro and modify loop are rewritten for the user's repo on the way. const skill = readFileSync(join(pkgRoot, 'src', 'generators', name, 'AGENTS.md'), 'utf-8'); - writeFileSync(join(outDir, `${name}.AGENTS.md`), ejectedSkill(skill, name)); + mkdirSync(join(skillsDir, `${name}-generator`), { recursive: true }); + writeFileSync(join(skillsDir, `${name}-generator`, 'SKILL.md'), ejectedSkill(skill, name)); } 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 c9ef73fc39..9a54a6f3f0 100644 --- a/packages/client-generator/src/generators/__tests__/generator-skills.test.ts +++ b/packages/client-generator/src/generators/__tests__/generator-skills.test.ts @@ -38,13 +38,15 @@ describe.each(EJECTABLE)('%s generator skill ships to users', (name) => { expect(readFileSync(skillPath, 'utf-8')).toContain(`${name}-runtime/`); }); - it('is what eject ships — the prepared asset is the user-repo transform of the source', () => { + it('is what eject ships — the prepared skill is the user-repo transform of the source', () => { // `prepare` rewrites the skill for the user's repo (their file is generators/.mjs, // their loop is regenerate + diff — not this repo's index.ts/prepare/vitest loop); // commit-time formatting of the source AFTER a prepare run would ship a stale copy. - const asset = join(generatorsDir, '../../eject-assets/generators', `${name}.AGENTS.md`); + const asset = join(generatorsDir, '../../eject-assets/skills', `${name}-generator`, 'SKILL.md'); const shipped = readFileSync(asset, 'utf-8'); expect(shipped).toBe(ejectedSkill(readFileSync(skillPath, 'utf-8'), name)); + // Eject drops it as an agent skill, so it carries the frontmatter a skill needs. + expect(shipped.startsWith(`---\nname: ${name}-generator\ndescription: `)).toBe(true); expect(shipped).toContain(`generators/${name}.mjs`); expect(shipped).not.toContain('index.ts'); expect(shipped).not.toContain('npm run prepare'); diff --git a/tests/e2e/generate-client/eject.test.ts b/tests/e2e/generate-client/eject.test.ts index 9b43669aab..2f4d766765 100644 --- a/tests/e2e/generate-client/eject.test.ts +++ b/tests/e2e/generate-client/eject.test.ts @@ -45,19 +45,28 @@ describe('eject-generator (end-to-end)', () => { rmSync(project, { recursive: true, force: true }); }, 60_000); - it('ejects php: file + pristine snapshot + AGENTS.md, and re-eject without --force errors', () => { + 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); expect(existsSync(join(project, 'generators/.pristine/php.mjs'))).toBe(true); - expect(readFileSync(join(project, 'generators/AGENTS.md'), 'utf-8')).toContain( - 'redocly-generators:begin' - ); - // The generator's OWN design skill ships alongside — the file an agent reads - // before editing the ejected generator. - expect(readFileSync(join(project, 'generators/php.AGENTS.md'), 'utf-8')).toContain( - 'edit this skill first' + + // The design ships where an agent auto-loads it, with skill frontmatter. + const design = readFileSync(join(project, '.claude/skills/php-generator/SKILL.md'), 'utf-8'); + expect(design).toContain('name: php-generator'); + expect(design).toContain('edit this skill first'); + // …together with the shared authoring skill (the toolkit and the model). + const authoring = readFileSync( + join(project, '.claude/skills/client-generators/SKILL.md'), + 'utf-8' ); + expect(authoring).toContain('name: client-generators'); + expect(authoring).toContain('flattenAllOf'); + // And a short pointer next to the code, so the directory explains itself. + const pointer = readFileSync(join(project, 'generators/AGENTS.md'), 'utf-8'); + expect(pointer).toContain('redocly-generators:begin'); + expect(pointer).toContain('.claude/skills/php-generator/SKILL.md'); + expect(run(project, ['eject-generator', 'php']).status).not.toBe(0); expect(run(project, ['eject-generator', 'php', '--force']).status).toBe(0); }, 60_000); diff --git a/tests/e2e/generate-client/examples.test.ts b/tests/e2e/generate-client/examples.test.ts index c90c116e08..85cb01c63b 100644 --- a/tests/e2e/generate-client/examples.test.ts +++ b/tests/e2e/generate-client/examples.test.ts @@ -84,20 +84,33 @@ describe('examples generate with the current generator', () => { } }); -describe('generator-authoring examples carry the current AGENTS.md', () => { - // The ejected example commits the AGENTS.md drop so browsers see the full - // story; this pins them byte-for-byte to the shipped template (markers included). - const template = readFileSync( - join(repoRoot, 'packages/client-generator/eject-assets/AGENTS.md'), - 'utf-8' - ).trim(); - const expected = `\n\n${template}\n\n\n`; - - it('ejected-generator/generators/AGENTS.md matches the shipped template', () => { - const dropped = readFileSync( - join(examplesDir, 'ejected-generator', 'generators/AGENTS.md'), +describe('the ejected example carries the current skills', () => { + // The example commits what `redocly eject-generator` drops, so a browser sees the + // whole story; these pin the committed copies to the shipped assets. + const shippedSkill = (skill: string) => + readFileSync( + join(repoRoot, 'packages/client-generator/eject-assets/skills', skill, 'SKILL.md'), 'utf-8' ); - expect(dropped, 'stale — re-run `redocly eject-generator` in the example').toBe(expected); + const exampleDir = join(examplesDir, 'ejected-generator'); + + it.each(['client-generators', 'php-generator'])( + '%s/SKILL.md matches the shipped skill', + (skill) => { + const committed = readFileSync( + join(exampleDir, '.claude/skills', skill, 'SKILL.md'), + 'utf-8' + ); + expect(committed, 'stale — re-run `redocly eject-generator` in the example').toBe( + shippedSkill(skill) + ); + } + ); + + it('generators/AGENTS.md points at both skills', () => { + const pointer = readFileSync(join(exampleDir, 'generators/AGENTS.md'), 'utf-8'); + expect(pointer).toContain('redocly-generators:begin'); + expect(pointer).toContain('.claude/skills/client-generators/SKILL.md'); + expect(pointer).toContain('.claude/skills/php-generator/SKILL.md'); }); }); diff --git a/tests/e2e/generate-client/examples/ejected-generator/.claude/skills/client-generators/SKILL.md b/tests/e2e/generate-client/examples/ejected-generator/.claude/skills/client-generators/SKILL.md new file mode 100644 index 0000000000..0c8fc535af --- /dev/null +++ b/tests/e2e/generate-client/examples/ejected-generator/.claude/skills/client-generators/SKILL.md @@ -0,0 +1,125 @@ +--- +name: client-generators +description: Write or change a Redocly client generator — the API model, the language-neutral helper toolkit, and the edit → regenerate → diff loop. +--- + +# Writing custom client generators + +A generator is a plain module: `(input) => GeneratedFile[]`. It receives the +language-agnostic API model and returns files — in ANY output language. It runs +in the same pass as the built-ins; select it by path in `redocly.yaml`: + +```yaml +client: + generators: [sdk, ./generators/my-generator.mjs] +``` + +## The contract + +```js +/** @type {import('@redocly/client-generator').CustomGenerator} */ +export default { + name: 'my-generator', + run({ model, outputPath, outputMode, emit }) { + return [{ path: outputPath.replace(/\.ts$/, '.mine.txt'), content: '…' }]; + }, + // Optional: one idiomatic call snippet per operation for docs (x-codeSamples), + // collected into an overlay file when `client.codeSamples: true` is set. + sample(operation, { model, emit }) { + return { lang: 'python', source: '…' }; + }, +}; +``` + +## Declaring options + +A generator that needs configuration declares it as a schema; `run` then receives +`options` already validated, with defaults applied: + +```js +export default { + name: 'permissions-matrix', + options: { + type: 'object', + properties: { groupBy: { enum: ['tag', 'path'], default: 'tag' } }, + additionalProperties: false, + }, + run({ model, outputPath, options }) { + return [ + { path: outputPath.replace(/\.ts$/, '.permissions.md'), content: render(options.groupBy) }, + ]; + }, +}; +``` + +Users set them per generator name: + +```yaml +client: + generators: [sdk, ./generators/permissions-matrix.mjs] + options: + permissions-matrix: + groupBy: path +``` + +The supported subset is a top-level `type: 'object'` with `properties`, `required`, and +`additionalProperties`; each property is a scalar (`string`/`number`/`boolean`), an +`enum`, or an array of scalars, and may carry a `default` and a `description`. Don't +validate options inside `run` — an unknown key, a wrong type, a value outside an `enum`, +or a missing `required` key already fails generation before `run` is called. + +Rules: output is deterministic (same description → same bytes); never add +dependencies to the generated client; **never hand-edit generated output** — +edit this generator and regenerate. Emitted file paths must stay inside the +`--output` directory (subdirectories are fine) — escapes are rejected. +Optionally declare `contract` (the `GENERATOR_CONTRACT` number exported by +`@redocly/client-generator`): a CLI whose contract differs then fails with the +fix path instead of feeding your generator an unexpected model shape. Ejected +generators carry it automatically. + +## The model (IR) + +`model.services[].operations[]` — each operation carries `name`, `specName`, +`method`, `path`, `tags`, `pathParams`/`queryParams`/`headerParams`/`cookieParams`, +`requestBody`, `successResponses`/`errorResponses` (each with a `schema`), and +`security`. `model.schemas` holds the named schemas. Every schema is a +discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, +`literal`, `enum`, `union` (optionally with a discriminator), `intersection` +(allOf), `null`, `unknown`, `omit`. + +## Helpers (import from '@redocly/client-generator') + +| Helper | Use | +| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| `flattenAllOf(schema, model)` | The merged property view of allOf compositions — languages without intersection types render this. | +| `discriminatorCases(schema, model)` | `{ property, cases }` dispatch table for discriminated unions. | +| `isNullable(schema)` / `unwrapNullable(schema)` | Detect and strip `null` union members (`Optional[T]`, pointers, `Option`). | +| `enumValues(schema)` | Values plus SCREAMING_SNAKE member-name suggestions. | +| `headerCoerceType(schema, model)` | Response-header coerce hint (`integer`/`number`/`boolean`/`string`) through refs, nullables, and allOf wrappers. | +| `casing` / `identifierFor(name, { style, reserved })` | camel/pascal/snake/screaming; keyword-safe identifiers (`RESERVED_WORDS.python/go/typescript` shipped). | +| `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. | +| `NotSupportedError` | Throw it to reject an option the generator can't honor — the CLI prints the message as a user error, not a crash. | +| `AUTHORING_HELPER_NAMES` | The list of the above (introspection). | + +Worked example: the built-in `python` generator +(`packages/client-generator/src/generators/python/index.ts` in the Redocly CLI repo) is +authored with exactly this toolkit and nothing else — models via `flattenAllOf`/ +`enumValues`/`discriminatorCases`, all code through `Printer`, every name through +`identifierFor(..., RESERVED_WORDS.python)`. + +TypeScript-emitting generators may additionally use the TS toolkit from +`@redocly/client-generator/generate` (`ts`, `printStatements`, `schemaToTypeNode`, …). + +## The loop + +1. Edit the generator. +2. Run `redocly generate-client`. +3. Inspect `git diff` of the generated output. +4. Repeat. Generated files are never hand-edited. + +If you had to work around a **missing helper** or a wrong default, tell the user +and suggest filing an issue at https://github.com/Redocly/redocly-cli/issues — +include the generator snippet and the helper you expected to exist. diff --git a/tests/e2e/generate-client/examples/ejected-generator/.claude/skills/php-generator/SKILL.md b/tests/e2e/generate-client/examples/ejected-generator/.claude/skills/php-generator/SKILL.md new file mode 100644 index 0000000000..c330d99da5 --- /dev/null +++ b/tests/e2e/generate-client/examples/ejected-generator/.claude/skills/php-generator/SKILL.md @@ -0,0 +1,97 @@ +--- +name: php-generator +description: Design of the ejected Redocly `php` client generator. Read it, and update it, before changing generators/php.mjs. +--- + +# The `php` generator — its skill + +This file is the DESIGN of your ejected `php` generator (`generators/php.mjs`): +**to change the generator, edit this skill first, then make the code match it** — a diff +to `generators/php.mjs` that has no covering sentence here is incomplete. + +## What it emits + +One self-contained `.php`: promoted-constructor model classes, a `Client` with one +typed method per operation, and the embedded runtime. PHP ≥ 8.1, HTTP over the curl +extension — zero Composer dependencies. The namespace derives from the API title +(`identifierFor(title, pascal)` — e.g. `CafeOrders`). + +## Design decisions that must hold + +- **Models are `final class`es** with constructor property promotion, required parameters + first, optionals nullable `= null`. Hydration is compile-time generated per class: + `fromArray(array $data): self` and `toArray(): array` (wire names inline; nulls + skipped on serialize) — no reflection. `omit` schemas hydrate/serialize through their + base class. A property or response typed as a DISCRIMINATED union hydrates through the + union's `unmarshalX` dispatcher, so consumers can narrow with `instanceof`; + undiscriminated unions stay raw arrays. +- The `Client` class is NOT `final` — PHP test suites mock concrete classes + (`createMock(Client::class)`), and `final` would force a wrapper interface on every + consumer. Model classes stay `final`. +- **Naming:** classes PascalCase, properties/methods camelCase via + `identifierFor(..., RESERVED_WORDS.php)`; reserved words get a trailing underscore. +- **Enums** are native backed enums (string/int); other scalars stay aliases. + **Discriminated unions** are `match`-based `unmarshalX(array $data)` dispatchers; + **allOf** is flattened. +- **Unions keep their types where PHP 8.1 can express them.** A union of scalars, enums, + classes, or arrays becomes a native union type (`int|string`, `PromotionType|array`) + rather than collapsing to `mixed` — rich list filters are the common case and losing + their types loses the point of a typed SDK. It falls back to `mixed` only when a member + has no PHP type of its own (an inline object, an intersection, `unknown`), because + `mixed` cannot appear inside a union. Nullability is expressed as `|null` in a union + (PHP forbids mixing `?` with `|`) and `?T` for a single type. +- **Errors:** exceptions ARE the error mode (`ApiError`/`TimeoutError` extend + `\RuntimeException`); `errorMode` does not change the output (the generator declares + `errorModes: ['throw']`, so `result` fails fast). +- **Dates:** `dateType: Date` types `format: date`/`date-time` as + `\DateTimeImmutable`; hydration is `new \DateTimeImmutable(...)` and serialization + formats with `\DateTimeInterface::ATOM` (date-time) or `'Y-m-d'` (date), including + for query parameters. +- **Method arguments:** required path params positional, JSON body next, optional query + params as nullable NAMED arguments, then `?array $headers`, and `?string +$idempotencyKey` on mutating methods. +- **Non-JSON success bodies** (PDFs, images, octet streams) return the raw body as + `string` — a binary download must never degrade to `void`. +- **PHPDoc carries what the signature cannot.** PHP's `array` and `\Generator` erase their + element type, so a docblock states it: `@return Customer[]` for collection returns and + `@return \Generator` on `Pages()`/`Items()`. Static analysis and + readers go by these; a hydrated return with no annotation looks untyped. +- **Response headers:** an operation that DECLARES success-response headers gains a + `WithHeaders()` variant returning an `Envelope` (`data`, `headers` — coerced to + int/bool/string with camelCase keys, absent/unparsable values omitted — and `status`). + Operations without declared headers get no variant, and the base method stays + body-only (PHP cannot vary a return type on a flag). +- **Servers:** when the description declares servers, a `Servers` class is emitted with + one static method per server; server VARIABLES become named string arguments defaulting + to the spec's defaults (`Servers::production(organizationId: 'org_x')`), so templated + base URLs need no manual string building. The client's baked default stays `servers[0]` + with variable defaults substituted. +- **Parity surface:** auth, retries with `Retry-After` + jittered backoff, per-attempt + curl timeouts, middleware callables, pagination (`Pages()` / `Items()` as + `\Generator`s), SSE (`iterSse` over a curl_multi pump), multipart. +- The runtime is hand-written in `php-runtime/runtime.php` (`php -l`-clean) and embedded + at prepare time. `curl_close` is never called (deprecated since PHP 8.5, no-op since 8.0). +- Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise. + +## Migrating from a service-based SDK + +- Per-resource services (`$client->customers()->get($id)`) map to flat methods named + after operationIds (`$client->getCustomer($id)`); optional query params keep their + named-argument style (`filter:`, `sort:`, `limit:`). +- Collection wrappers exposing pagination RESPONSE HEADERS (`getTotalItems()`, + `getLimit()`) map to the `WithHeaders()` envelope + (`->headers['paginationTotal']`); plain iteration maps to `Items()` / + `Pages()` generators. +- Dedicated validation-exception classes exposing field errors map to + `catch (ApiError $e)` + `$e->status === 422` + the decoded `$e->body`. +- Session/bearer token flows map to `auth: ['bearer' => $tokenProvider]` with a + callable — resolved per request, so refresh needs no client rebuild. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Make `generators/php.mjs` match it. +3. Run `redocly generate-client` and inspect the `git diff` of the generated output — + generated files are never hand-edited. + +Newer built-in versions merge in with `redocly eject-generator php --update`. diff --git a/tests/e2e/generate-client/examples/ejected-generator/README.md b/tests/e2e/generate-client/examples/ejected-generator/README.md index 9ea797aa12..edd07ec39a 100644 --- a/tests/e2e/generate-client/examples/ejected-generator/README.md +++ b/tests/e2e/generate-client/examples/ejected-generator/README.md @@ -9,7 +9,9 @@ head src/api/client.php # the customized banner is in the generated header npm run update-generator # merge a newer generator version into the customized copy ``` -`generators/AGENTS.md` (committed here, exactly as the command drops it) is the authoring guide your coding agent reads before editing the generator — point your agent at it and describe the change you want. +`.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. `generators/.pristine/php.mjs` (committed, as it should be in your repo too) is the merge base: `npm run update-generator` three-way-merges a newer generator version into the customized copy — clean hunks apply silently, real conflicts get standard markers. This example started from `redocly eject-generator php`; run that in your own repo to begin. The ejected file imports the authoring toolkit and the embedded runtime from `@redocly/client-generator`, so runtime fixes still arrive with plain `npm update` — no merge needed. diff --git a/tests/e2e/generate-client/examples/ejected-generator/generators/AGENTS.md b/tests/e2e/generate-client/examples/ejected-generator/generators/AGENTS.md index 822fa0144d..f26d20614a 100644 --- a/tests/e2e/generate-client/examples/ejected-generator/generators/AGENTS.md +++ b/tests/e2e/generate-client/examples/ejected-generator/generators/AGENTS.md @@ -1,86 +1,12 @@ -# Writing custom client generators +# Ejected client generators -A generator is a plain module: `(input) => GeneratedFile[]`. It receives the -language-agnostic API model and returns files — in ANY output language. It runs -in the same pass as the built-ins; select it by path in `redocly.yaml`: +These files are Redocly client generators you own; `redocly generate-client` runs them. +Their design and the authoring toolkit are agent skills — edit the skill first, then make +the code match, and never hand-edit generated client output: -```yaml -client: - generators: [sdk, ./generators/my-generator.mjs] -``` - -## The contract - -```js -/** @type {import('@redocly/client-generator').CustomGenerator} */ -export default { - name: 'my-generator', - run({ model, outputPath, outputMode, emit }) { - return [{ path: outputPath.replace(/\.ts$/, '.mine.txt'), content: '…' }]; - }, - // Optional: one idiomatic call snippet per operation for docs (x-codeSamples), - // collected into an overlay file when `client.codeSamples: true` is set. - sample(operation, { model, emit }) { - return { lang: 'python', source: '…' }; - }, -}; -``` - -Rules: output is deterministic (same description → same bytes); never add -dependencies to the generated client; **never hand-edit generated output** — -edit this generator and regenerate. Emitted file paths must stay inside the -`--output` directory (subdirectories are fine) — escapes are rejected. -Optionally declare `contract` (the `GENERATOR_CONTRACT` number exported by -`@redocly/client-generator`): a CLI whose contract differs then fails with the -fix path instead of feeding your generator an unexpected model shape. Ejected -generators carry it automatically. - -## The model (IR) - -`model.services[].operations[]` — each operation carries `name`, `specName`, -`method`, `path`, `tags`, `pathParams`/`queryParams`/`headerParams`/`cookieParams`, -`requestBody`, `successResponses`/`errorResponses` (each with a `schema`), and -`security`. `model.schemas` holds the named schemas. Every schema is a -discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, -`literal`, `enum`, `union` (optionally with a discriminator), `intersection` -(allOf), `null`, `unknown`, `omit`. - -## Helpers (import from '@redocly/client-generator') - -| Helper | Use | -| ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | -| `flattenAllOf(schema, model)` | The merged property view of allOf compositions — languages without intersection types render this. | -| `discriminatorCases(schema, model)` | `{ property, cases }` dispatch table for discriminated unions. | -| `isNullable(schema)` / `unwrapNullable(schema)` | Detect and strip `null` union members (`Optional[T]`, pointers, `Option`). | -| `enumValues(schema)` | Values plus SCREAMING_SNAKE member-name suggestions. | -| `headerCoerceType(schema, model)` | Response-header coerce hint (`integer`/`number`/`boolean`/`string`) through refs, nullables, and allOf wrappers. | -| `casing` / `identifierFor(name, { style, reserved })` | camel/pascal/snake/screaming; keyword-safe identifiers (`RESERVED_WORDS.python/go/typescript` shipped). | -| `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. | -| `AUTHORING_HELPER_NAMES` | The list of the above (introspection). | - -Worked example: the built-in `python` generator -(`packages/client-generator/src/generators/python/index.ts` in the Redocly CLI repo) is -authored with exactly this toolkit and nothing else — models via `flattenAllOf`/ -`enumValues`/`discriminatorCases`, all code through `Printer`, every name through -`identifierFor(..., RESERVED_WORDS.python)`. - -TypeScript-emitting generators may additionally use the TS toolkit from -`@redocly/client-generator/generate` (`ts`, `printStatements`, `schemaToTypeNode`, …). - -## The loop - -1. Edit the generator. -2. Run `redocly generate-client`. -3. Inspect `git diff` of the generated output. -4. Repeat. Generated files are never hand-edited. - -If you had to work around a **missing helper** or a wrong default, tell the user -and suggest filing an issue at https://github.com/Redocly/redocly-cli/issues — -include the generator snippet and the helper you expected to exist. +- `.claude/skills/client-generators/SKILL.md` — the API model, the helpers, the loop. +- `.claude/skills/php-generator/SKILL.md` — the `php` generator's design. From 6e9b4043fa26fbcbcde55ff69beb64af7b7271c3 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 5 Aug 2026 21:51:35 +0300 Subject: [PATCH 104/211] feat: eject wires up the dependency and the config entry --- .changeset/agent-friendly-generators.md | 2 + packages/cli/src/commands/eject-generator.ts | 95 ++++++++++++++++++-- tests/e2e/generate-client/eject.test.ts | 38 ++++++++ 3 files changed, 129 insertions(+), 6 deletions(-) diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index e42713a2b5..51f103f314 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -12,3 +12,5 @@ Added `goPackage` (`--go-package`) to set the package clause of the `go` generat A custom generator can now declare its own options as a schema; publishers set them under `client.options.` and the values are validated — unknown key, wrong type, value outside an `enum`, missing required option — before anything is written, with defaults applied when `run` receives them. **Note:** the per-operation pagination extension is now `x-redoclyPagination`, matching the camelCase of every other Redocly extension. Rename it in descriptions that declared `x-redocly-pagination`; the old spelling is no longer read. + +`eject-generator` now wires itself up: it records `@redocly/client-generator` in your `devDependencies` and adds the ejected file to `client.generators`, printing the snippet to add by hand only when the configuration file has a shape it won't edit blind. diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts index 90da23a56a..742e95296b 100644 --- a/packages/cli/src/commands/eject-generator.ts +++ b/packages/cli/src/commands/eject-generator.ts @@ -5,6 +5,7 @@ import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { ejectGeneratorTelemetry } from '../utils/generate-client-telemetry.js'; +import { version } from '../utils/package.js'; import { type CommandArgs } from '../wrapper.js'; export type EjectGeneratorCommandArgv = { @@ -30,6 +31,9 @@ const TS_BUILTINS = new Set([ 'cli', ]); +/** The package an ejected generator imports its toolkit from; recorded as a devDependency. */ +const TOOLKIT_PACKAGE = '@redocly/client-generator'; + const AGENTS_BEGIN = ''; const AGENTS_END = ''; @@ -140,7 +144,79 @@ function ejectedIn(dir: string): string[] { return [...EJECTABLE].filter((name) => existsSync(join(dir, `${name}.mjs`))); } -export const handleEjectGenerator = async ({ argv }: CommandArgs) => { +/** + * Record `@redocly/client-generator` in the project's devDependencies — the ejected file + * imports the authoring toolkit from it. Installing stays the user's call; this only makes + * the requirement part of the project so a fresh clone or CI gets it. Returns what happened. + */ +function wireDependency(): 'added' | 'present' | 'no-package-json' { + const manifestPath = join(process.cwd(), 'package.json'); + if (!existsSync(manifestPath)) return 'no-package-json'; + const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as { + dependencies?: Record; + devDependencies?: Record; + }; + if ( + manifest.dependencies?.[TOOLKIT_PACKAGE] !== undefined || + manifest.devDependencies?.[TOOLKIT_PACKAGE] !== undefined + ) { + return 'present'; + } + const devDependencies = { ...manifest.devDependencies, [TOOLKIT_PACKAGE]: `^${version}` }; + manifest.devDependencies = Object.fromEntries( + Object.entries(devDependencies).sort(([left], [right]) => left.localeCompare(right)) + ); + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf-8'); + return 'added'; +} + +/** + * Add the ejected file to `client.generators` in the configuration file, editing the text + * so comments and formatting survive. Only the two shapes we can extend without guessing + * are handled — a block sequence and a flow sequence under a top-level `client:` — and + * anything else returns false, so the caller prints the snippet instead of reshaping + * someone's config. + */ +function wireConfig(configPath: string | undefined, entry: string): boolean { + if (configPath === undefined || !existsSync(configPath)) return false; + const source = readFileSync(configPath, 'utf-8'); + const lines = source.split('\n'); + const clientLine = lines.findIndex((line) => /^client:\s*$/.test(line)); + if (clientLine === -1) return false; + const generatorsLine = lines.findIndex( + (line, index) => index > clientLine && /^\s+generators:/.test(line) + ); + if (generatorsLine === -1) return false; + // Between `client:` and `generators:` there must be nothing dedented — otherwise the + // `generators:` we found belongs to another block. + if (lines.slice(clientLine + 1, generatorsLine).some((line) => /^\S/.test(line))) return false; + if (source.includes(entry)) return true; + + const flow = lines[generatorsLine].match(/^(\s+generators:\s*\[)(.*)\]\s*$/); + if (flow !== null) { + const existing = flow[2].trim(); + lines[generatorsLine] = `${flow[1]}${existing === '' ? '' : `${existing}, `}${entry}]`; + writeFileSync(configPath, lines.join('\n'), 'utf-8'); + return true; + } + if (!/^\s+generators:\s*$/.test(lines[generatorsLine])) return false; + let lastItem = generatorsLine; + let itemIndent = `${lines[generatorsLine].match(/^\s+/)![0]} `; + for (let index = generatorsLine + 1; index < lines.length; index++) { + const item = lines[index].match(/^(\s+)- /); + if (item === null) break; + lastItem = index; + itemIndent = item[1]; + } + lines.splice(lastItem + 1, 0, `${itemIndent}- ${entry}`); + writeFileSync(configPath, lines.join('\n'), 'utf-8'); + return true; +} + +export const handleEjectGenerator = async ({ + argv, + config, +}: CommandArgs) => { const name = argv.generator ?? ''; // Coarse usage telemetry: our command action, an ALLOWLISTED built-in name, and the // outcome category — never user paths, file contents, or user-chosen names. @@ -217,13 +293,20 @@ export const handleEjectGenerator = async ({ argv }: CommandArgs { expect(run(project, ['eject-generator', 'php', '--force']).status).toBe(0); }, 60_000); + it('wires itself up: devDependency recorded and the config entry added, once', () => { + const wired = mkdtempSync(join(tmpdir(), 'eject-wire-')); + try { + writeFileSync(join(wired, 'package.json'), JSON.stringify({ name: 'demo' }), 'utf-8'); + writeFileSync( + join(wired, 'redocly.yaml'), + 'extends: []\nclient:\n generators:\n - sdk\n', + 'utf-8' + ); + const eject = run(wired, ['eject-generator', 'go']); + expect(eject.status, eject.stderr).toBe(0); + + const pkg = JSON.parse(readFileSync(join(wired, 'package.json'), 'utf-8')); + expect(pkg.devDependencies['@redocly/client-generator']).toMatch(/^\^\d+\./); + expect(readFileSync(join(wired, 'redocly.yaml'), 'utf-8')).toBe( + 'extends: []\nclient:\n generators:\n - sdk\n - ./generators/go.mjs\n' + ); + + // Re-ejecting must not add the entry twice. + expect(run(wired, ['eject-generator', 'go', '--force']).status).toBe(0); + expect(readFileSync(join(wired, 'redocly.yaml'), 'utf-8').match(/go\.mjs/g)).toHaveLength(1); + } finally { + rmSync(wired, { recursive: true, force: true }); + } + }, 60_000); + + it('prints the config snippet when it cannot safely edit the config', () => { + const manual = mkdtempSync(join(tmpdir(), 'eject-manual-')); + try { + const eject = run(manual, ['eject-generator', 'go']); + expect(eject.status, eject.stderr).toBe(0); + expect(eject.stderr + eject.stdout).toContain('generators:'); + expect(eject.stderr + eject.stdout).toContain('./generators/go.mjs'); + } finally { + rmSync(manual, { recursive: true, force: true }); + } + }, 60_000); + it('THE headline: an ejected-unmodified generator produces byte-identical output', () => { const builtin = run(project, [ 'generate-client', From 327eda42d6923219df51a3702cd27cac4a2df3b2 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 5 Aug 2026 22:36:42 +0300 Subject: [PATCH 105/211] feat!: replace the generator contract number with a semver range --- .changeset/agent-friendly-generators.md | 2 + .../@v2/guides/customize-client-generation.md | 2 + .../client-generator/eject-assets/AGENTS.md | 9 ++-- .../skills/client-generators/SKILL.md | 9 ++-- .../scripts/generate-eject-assets.mjs | 13 ++---- .../__tests__/compatibility.test.ts | 37 ++++++++++++++++ .../src/generators/__tests__/resolve.test.ts | 41 ++++++++++++----- .../src/generators/compatibility.ts | 44 +++++++++++++++++++ .../src/generators/contract.ts | 12 ----- .../src/generators/resolve.ts | 26 ++++++----- .../client-generator/src/generators/types.ts | 10 ++--- .../__tests__/contract-shape.test.ts | 9 ++-- packages/client-generator/src/plugin.ts | 2 +- 13 files changed, 156 insertions(+), 60 deletions(-) create mode 100644 packages/client-generator/src/generators/__tests__/compatibility.test.ts create mode 100644 packages/client-generator/src/generators/compatibility.ts delete mode 100644 packages/client-generator/src/generators/contract.ts diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index 51f103f314..0e6158c6be 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -14,3 +14,5 @@ A custom generator can now declare its own options as a schema; publishers set t **Note:** the per-operation pagination extension is now `x-redoclyPagination`, matching the camelCase of every other Redocly extension. Rename it in descriptions that declared `x-redocly-pagination`; the old spelling is no longer read. `eject-generator` now wires itself up: it records `@redocly/client-generator` in your `devDependencies` and adds the ejected file to `client.generators`, printing the snippet to add by hand only when the configuration file has a shape it won't edit blind. + +Generator compatibility is the package version under semver instead of a separate contract number: a generator declares the range it was written against with `requiresGenerator` (`^1.2.0`, `~1.2.0`, `>=1.2.0`, or an exact version), and a CLI outside that range says which version it ships and how to fix it. `GENERATOR_CONTRACT` is gone; ejected generators record the range for you. diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index 5e42cd6b62..e380843746 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -85,6 +85,8 @@ Emitted file paths must stay inside the `--output` directory — subdirectories The API model and the helper library are the generator contract, and it changes under semver: a breaking change bumps the major version (the minor, while the package is `0.x`). Declare the version you authored against with `requiresGenerator: '^1.2.0'`, and an incompatible CLI fails upfront — naming the version it has, the version you need, and the upgrade — instead of feeding your generator a model shape it doesn't expect. Ejected generators record it for you. +The accepted range forms are `^1.2.0`, `~1.2.0`, `>=1.2.0`, and an exact `1.2.0`; anything else is rejected as unreadable rather than guessed at. +Omitting `requiresGenerator` means "assume current" — convenient while you iterate, and worth setting before you share the generator. **A generator can declare its own options** with a JSON Schema, so publishers configure it the way they configure the built-ins: diff --git a/packages/client-generator/eject-assets/AGENTS.md b/packages/client-generator/eject-assets/AGENTS.md index 60411058ab..c6ecc401ec 100644 --- a/packages/client-generator/eject-assets/AGENTS.md +++ b/packages/client-generator/eject-assets/AGENTS.md @@ -67,10 +67,11 @@ Rules: output is deterministic (same description → same bytes); never add dependencies to the generated client; **never hand-edit generated output** — edit this generator and regenerate. Emitted file paths must stay inside the `--output` directory (subdirectories are fine) — escapes are rejected. -Optionally declare `contract` (the `GENERATOR_CONTRACT` number exported by -`@redocly/client-generator`): a CLI whose contract differs then fails with the -fix path instead of feeding your generator an unexpected model shape. Ejected -generators carry it automatically. +Optionally declare `requiresGenerator` — the `@redocly/client-generator` version +range you wrote this against (`'^1.2.0'`, `'~1.2.0'`, `'>=1.2.0'`, or an exact +version). A CLI outside the range then fails with the fix path instead of feeding +your generator an unexpected model shape. Ejected generators carry it +automatically; hand-written ones without it are taken as current. ## The model (IR) 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 0c8fc535af..1885959c57 100644 --- a/packages/client-generator/eject-assets/skills/client-generators/SKILL.md +++ b/packages/client-generator/eject-assets/skills/client-generators/SKILL.md @@ -72,10 +72,11 @@ Rules: output is deterministic (same description → same bytes); never add dependencies to the generated client; **never hand-edit generated output** — edit this generator and regenerate. Emitted file paths must stay inside the `--output` directory (subdirectories are fine) — escapes are rejected. -Optionally declare `contract` (the `GENERATOR_CONTRACT` number exported by -`@redocly/client-generator`): a CLI whose contract differs then fails with the -fix path instead of feeding your generator an unexpected model shape. Ejected -generators carry it automatically. +Optionally declare `requiresGenerator` — the `@redocly/client-generator` version +range you wrote this against (`'^1.2.0'`, `'~1.2.0'`, `'>=1.2.0'`, or an exact +version). A CLI outside the range then fails with the fix path instead of feeding +your generator an unexpected model shape. Ejected generators carry it +automatically; hand-written ones without it are taken as current. ## The model (IR) diff --git a/packages/client-generator/scripts/generate-eject-assets.mjs b/packages/client-generator/scripts/generate-eject-assets.mjs index 040b0cc2ef..28487a9f6c 100644 --- a/packages/client-generator/scripts/generate-eject-assets.mjs +++ b/packages/client-generator/scripts/generate-eject-assets.mjs @@ -13,15 +13,6 @@ import { ejectedSkill } from './ejected-skill.mjs'; // these into the user's repo verbatim. const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); const { version } = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8')); -// The contract number lives in ONE place (src/generators/contract.ts); this script -// runs at prepare time (before tsc), so it reads the constant out of the source. -const contractSource = readFileSync(join(pkgRoot, 'src', 'generators', 'contract.ts'), 'utf-8'); -const contractMatch = contractSource.match(/GENERATOR_CONTRACT = (\d+)/); -if (contractMatch === null) { - process.stderr.write('Could not read GENERATOR_CONTRACT from src/generators/contract.ts\n'); - process.exit(1); -} -const contract = Number(contractMatch[1]); const outDir = join(pkgRoot, 'eject-assets', 'generators'); const skillsDir = join(pkgRoot, 'eject-assets', 'skills'); mkdirSync(outDir, { recursive: true }); @@ -69,7 +60,9 @@ for (const { name, run, sample } of EJECTABLE) { '// `redocly eject-generator ' + name + ' --update`.', '', ].join('\n'); - const footer = `\nexport default {\n name: '${name}',\n run: ${run},\n sample: ${sample},\n contract: ${contract},\n};\n`; + // The caret range the ejected copy was written against: this version's model and + // helpers, plus every compatible release after it. + const footer = `\nexport default {\n name: '${name}',\n run: ${run},\n sample: ${sample},\n requiresGenerator: '^${version}',\n};\n`; const outFile = join(outDir, `${name}.mjs`); writeFileSync(outFile, header + stripped + footer); const check = spawnSync(process.execPath, ['--check', outFile], { encoding: 'utf-8' }); diff --git a/packages/client-generator/src/generators/__tests__/compatibility.test.ts b/packages/client-generator/src/generators/__tests__/compatibility.test.ts new file mode 100644 index 0000000000..9a78b4f206 --- /dev/null +++ b/packages/client-generator/src/generators/__tests__/compatibility.test.ts @@ -0,0 +1,37 @@ +import { GENERATOR_VERSION, satisfiesGeneratorRange } from '../compatibility.js'; + +describe('satisfiesGeneratorRange', () => { + it('reads caret ranges, which are the ones ejected generators carry', () => { + expect(satisfiesGeneratorRange('1.4.2', '^1.2.0')).toBe(true); + expect(satisfiesGeneratorRange('1.2.0', '^1.2.0')).toBe(true); + expect(satisfiesGeneratorRange('1.1.9', '^1.2.0')).toBe(false); + expect(satisfiesGeneratorRange('2.0.0', '^1.2.0')).toBe(false); + // While the package is 0.x the minor is the breaking position, so a caret pins it. + expect(satisfiesGeneratorRange('0.2.9', '^0.2.1')).toBe(true); + expect(satisfiesGeneratorRange('0.3.0', '^0.2.1')).toBe(false); + }); + + it('reads tilde, >=, and exact ranges', () => { + expect(satisfiesGeneratorRange('1.2.9', '~1.2.0')).toBe(true); + expect(satisfiesGeneratorRange('1.3.0', '~1.2.0')).toBe(false); + expect(satisfiesGeneratorRange('9.9.9', '>=1.2.0')).toBe(true); + expect(satisfiesGeneratorRange('1.1.0', '>=1.2.0')).toBe(false); + expect(satisfiesGeneratorRange('1.2.0', '1.2.0')).toBe(true); + expect(satisfiesGeneratorRange('1.2.1', '1.2.0')).toBe(false); + }); + + it('compares numerically, not as strings, and ignores a prerelease suffix', () => { + expect(satisfiesGeneratorRange('1.10.0', '^1.9.0')).toBe(true); + expect(satisfiesGeneratorRange('2.0.0-snapshot.1', '^2.0.0')).toBe(true); + }); + + it('returns undefined for a range it does not read, so the caller can say so', () => { + for (const range of ['1.x || 2', '>1.2.0 <2.0.0', 'latest', '', 'v1']) { + expect(satisfiesGeneratorRange('1.2.0', range)).toBeUndefined(); + } + }); + + it('exposes the running toolkit version', () => { + expect(GENERATOR_VERSION).toMatch(/^\d+\.\d+\.\d+/); + }); +}); diff --git a/packages/client-generator/src/generators/__tests__/resolve.test.ts b/packages/client-generator/src/generators/__tests__/resolve.test.ts index 5dd2786d5b..1c084d65ab 100644 --- a/packages/client-generator/src/generators/__tests__/resolve.test.ts +++ b/packages/client-generator/src/generators/__tests__/resolve.test.ts @@ -1,7 +1,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { GENERATOR_CONTRACT } from '../contract.js'; +import { GENERATOR_VERSION } from '../compatibility.js'; import { resolveGenerators } from '../resolve.js'; import type { CustomGenerator } from '../types.js'; @@ -49,19 +49,40 @@ describe('resolveGenerators', () => { expect(explicit.selected).toEqual(['sdk', 'zod', 'cli']); }); - it('accepts a generator declaring the current contract; rejects any other with the fix path', async () => { - const current: CustomGenerator = { name: 'ok', run: noopRun, contract: GENERATOR_CONTRACT }; - await expect(resolveGenerators(['ok'], { customGenerators: [current] })).resolves.toBeTruthy(); + it('accepts a generator whose requiresGenerator range covers the running version', async () => { + const [major, minor] = GENERATOR_VERSION.split('.'); + const covering: CustomGenerator = { + name: 'ok', + run: noopRun, + requiresGenerator: `^${major}.${minor}.0`, + }; + await expect(resolveGenerators(['ok'], { customGenerators: [covering] })).resolves.toBeTruthy(); - const stale: CustomGenerator = { name: 'old', run: noopRun, contract: GENERATOR_CONTRACT - 1 }; - await expect(resolveGenerators(['old'], { customGenerators: [stale] })).rejects.toThrow( - /declares generator contract \d+.*provides \d+.*eject-generator/s + // A generator written against a newer toolkit than this CLI ships. + const ahead: CustomGenerator = { + name: 'ahead', + run: noopRun, + requiresGenerator: `>=${Number(major) + 1}.0.0`, + }; + await expect(resolveGenerators(['ahead'], { customGenerators: [ahead] })).rejects.toThrow( + new RegExp( + `"ahead" needs @redocly/client-generator >=${Number(major) + 1}\\.0\\.0.*this CLI ships ${GENERATOR_VERSION}`, + 's' + ) ); - const future: CustomGenerator = { name: 'new', run: noopRun, contract: GENERATOR_CONTRACT + 1 }; - await expect(resolveGenerators(['new'], { customGenerators: [future] })).rejects.toThrow( - /Update @redocly\/cli/ + // A generator pinned to a toolkit older than the one running: update the generator. + const behind: CustomGenerator = { name: 'behind', run: noopRun, requiresGenerator: '0.0.1' }; + await expect(resolveGenerators(['behind'], { customGenerators: [behind] })).rejects.toThrow( + /eject-generator/ ); + + // An unreadable range is rejected as such — never guessed at. + const vague: CustomGenerator = { name: 'vague', run: noopRun, requiresGenerator: '1.x || 2' }; + await expect(resolveGenerators(['vague'], { customGenerators: [vague] })).rejects.toThrow( + /requiresGenerator "1.x \|\| 2", which is not a range we read/ + ); + // No declaration keeps friction-free authoring — accepted as current. const undeclared: CustomGenerator = { name: 'bare', run: noopRun }; await expect( diff --git a/packages/client-generator/src/generators/compatibility.ts b/packages/client-generator/src/generators/compatibility.ts new file mode 100644 index 0000000000..fa438d5d4a --- /dev/null +++ b/packages/client-generator/src/generators/compatibility.ts @@ -0,0 +1,44 @@ +// Generator compatibility is the package version under semver: the API model and the +// authoring helpers ARE the contract, and a breaking change to either bumps the major +// (the minor while the package is 0.x). A generator declares the range it was written +// against with `requiresGenerator`, and a CLI outside that range refuses to run it. + +import packageJson from '../../package.json' with { type: 'json' }; + +/** The `@redocly/client-generator` version providing the model and helpers right now. */ +export const GENERATOR_VERSION: string = packageJson.version; + +type Semver = [major: number, minor: number, patch: number]; + +function parse(version: string): Semver | undefined { + // A prerelease (`2.0.0-snapshot.3`) is treated as its release version: snapshots exist + // to test the release they precede, so they must satisfy the same ranges. + const match = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version.trim()); + return match === null ? undefined : [Number(match[1]), Number(match[2]), Number(match[3])]; +} + +function compare(left: Semver, right: Semver): number { + return left[0] - right[0] || left[1] - right[1] || left[2] - right[2]; +} + +/** + * Whether `version` satisfies `range`, for the four forms a generator may declare: + * `^1.2.0`, `~1.2.0`, `>=1.2.0`, and an exact `1.2.0`. `undefined` means the range + * isn't one of those — the caller reports that instead of guessing an answer, since a + * misread range would either block a working generator or admit a broken one. + */ +export function satisfiesGeneratorRange(version: string, range: string): boolean | undefined { + const operator = /^[\^~]|^>=/.exec(range.trim())?.[0] ?? ''; + const lower = parse(range.trim().slice(operator.length)); + const actual = parse(version); + if (lower === undefined || actual === undefined) return undefined; + if (compare(actual, lower) < 0) return false; + if (operator === '>=') return true; + if (operator === '') return compare(actual, lower) === 0; + if (operator === '~') return actual[0] === lower[0] && actual[1] === lower[1]; + // Caret keeps the leftmost NON-ZERO position fixed: ^1.2.0 allows any 1.x, ^0.2.1 allows + // 0.2.x, ^0.0.3 allows only 0.0.3. + if (lower[0] !== 0) return actual[0] === lower[0]; + if (lower[1] !== 0) return actual[0] === 0 && actual[1] === lower[1]; + return compare(actual, lower) === 0; +} diff --git a/packages/client-generator/src/generators/contract.ts b/packages/client-generator/src/generators/contract.ts deleted file mode 100644 index f8ebb7515a..0000000000 --- a/packages/client-generator/src/generators/contract.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * The custom-generator contract version: the shape of the IR (`ApiModel`), the - * `GeneratorInput`, and the authoring helpers a generator is written against. - * - * Bump ONLY on a breaking change to any of those (removing/renaming a field, - * changing semantics) — additive changes keep the number. A generator that - * declares a different contract is rejected at resolve time with the fix path, - * so a breaking change surfaces as one clear message instead of silently wrong - * output. Ejected generators are stamped with the current value at prepare time - * (see scripts/generate-eject-assets.mjs, which reads this file). - */ -export const GENERATOR_CONTRACT = 1; diff --git a/packages/client-generator/src/generators/resolve.ts b/packages/client-generator/src/generators/resolve.ts index f88162115d..0cdad2776e 100644 --- a/packages/client-generator/src/generators/resolve.ts +++ b/packages/client-generator/src/generators/resolve.ts @@ -10,7 +10,7 @@ import { isAbsolute, resolve as resolvePath } from 'node:path'; import { pathToFileURL } from 'node:url'; import { NotSupportedError } from '../errors.js'; -import { GENERATOR_CONTRACT } from './contract.js'; +import { GENERATOR_VERSION, satisfiesGeneratorRange } from './compatibility.js'; import { BUILTIN_META, type BuiltinMeta } from './meta.js'; import type { CustomGenerator, GeneratorDescriptor } from './types.js'; @@ -109,15 +109,21 @@ function register(registry: Map, custom: CustomGene `Generator name "${custom.name}" collides with an existing generator. Rename the custom generator.` ); } - // A declared contract must match exactly — the number only moves on breaking - // changes, so any difference means the generator and this CLI disagree on the IR. - if (custom.contract !== undefined && custom.contract !== GENERATOR_CONTRACT) { - throw new NotSupportedError( - `Generator "${custom.name}" declares generator contract ${custom.contract}; this CLI provides ${GENERATOR_CONTRACT}. ` + - (custom.contract > GENERATOR_CONTRACT - ? 'Update @redocly/cli.' - : 'Update the generator — `redocly eject-generator --update` for ejected files, or upgrade the package.') - ); + // The model and the helpers change under semver, so a declared range that excludes the + // running version means the generator and this CLI disagree on the contract. + if (custom.requiresGenerator !== undefined) { + const satisfied = satisfiesGeneratorRange(GENERATOR_VERSION, custom.requiresGenerator); + if (satisfied === undefined) { + throw new NotSupportedError( + `Generator "${custom.name}" declares requiresGenerator "${custom.requiresGenerator}", which is not a range we read. Use ^1.2.0, ~1.2.0, >=1.2.0, or an exact version.` + ); + } + if (!satisfied) { + throw new NotSupportedError( + `Generator "${custom.name}" needs @redocly/client-generator ${custom.requiresGenerator}; this CLI ships ${GENERATOR_VERSION}. ` + + 'Upgrade @redocly/cli if the generator is newer, or update the generator — `redocly eject-generator --update` for an ejected file, or upgrade its package.' + ); + } } // A custom generator MAY take over a built-in name — that's how an ejected // generator replaces its origin without a config rename. Announce the takeover. diff --git a/packages/client-generator/src/generators/types.ts b/packages/client-generator/src/generators/types.ts index 6dcc6ef1d4..8aaf5fd4d3 100644 --- a/packages/client-generator/src/generators/types.ts +++ b/packages/client-generator/src/generators/types.ts @@ -129,10 +129,10 @@ export type CustomGenerator = GeneratorDescriptor & { /** Unique name, used in `generators` selection, `requires`, and collision detection. */ name: string; /** - * The generator contract this module was written against (see `GENERATOR_CONTRACT`). - * A declared mismatch is rejected at resolve time with the fix path; omitting it - * accepts the generator as current (friction-free hand authoring). Ejected - * generators carry it automatically. + * The `@redocly/client-generator` version range this module was written against — + * `^1.2.0`, `~1.2.0`, `>=1.2.0`, or an exact version. A CLI outside the range is + * rejected at resolve time with the fix path; omitting it accepts the generator as + * current (friction-free hand authoring). Ejected generators carry it automatically. */ - contract?: number; + requiresGenerator?: string; }; diff --git a/packages/client-generator/src/intermediate-representation/__tests__/contract-shape.test.ts b/packages/client-generator/src/intermediate-representation/__tests__/contract-shape.test.ts index a34b83024f..909b80ad38 100644 --- a/packages/client-generator/src/intermediate-representation/__tests__/contract-shape.test.ts +++ b/packages/client-generator/src/intermediate-representation/__tests__/contract-shape.test.ts @@ -1,9 +1,10 @@ // The IR is the custom-generator contract: every field below is public API that // ejected and custom generators read. If this snapshot changes, decide whether the -// change is ADDITIVE (update the snapshot, contract number stays) or BREAKING -// (removed/renamed field, changed semantics — bump GENERATOR_CONTRACT in -// generators/contract.ts so mismatched generators fail with the fix path instead -// of misbehaving). +// change is ADDITIVE (update the snapshot and ship it in any release) or BREAKING +// (a removed/renamed field, or changed semantics), which needs a major release — +// the minor while the package is 0.x. A generator's `requiresGenerator` range is +// resolved against that version, so a breaking change stops incompatible +// generators with the fix path instead of letting them misbehave. import type { Oas3Definition } from '@redocly/openapi-core'; diff --git a/packages/client-generator/src/plugin.ts b/packages/client-generator/src/plugin.ts index f003863598..803d28fe5e 100644 --- a/packages/client-generator/src/plugin.ts +++ b/packages/client-generator/src/plugin.ts @@ -28,7 +28,7 @@ import type { CustomGenerator } from './generators/types.js'; -export { GENERATOR_CONTRACT } from './generators/contract.js'; +export { GENERATOR_VERSION } from './generators/compatibility.js'; /** * Identity helper for authoring a custom generator with full type inference and one validation From 05a12ffd5369a5ff17da554f5157aa8f6c8d3656 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 5 Aug 2026 23:03:16 +0300 Subject: [PATCH 106/211] feat: resolve the update merge base from the ejected header instead of a committed snapshot --- .changeset/agent-friendly-generators.md | 2 + .../cli/src/__tests__/eject-generator.test.ts | 28 + packages/cli/src/commands/eject-generator.ts | 118 +++- tests/e2e/generate-client/eject.test.ts | 35 +- .../.claude/skills/client-generators/SKILL.md | 9 +- .../examples/ejected-generator/README.md | 3 +- .../generators/.pristine/php.mjs | 576 ------------------ 7 files changed, 148 insertions(+), 623 deletions(-) create mode 100644 packages/cli/src/__tests__/eject-generator.test.ts delete mode 100644 tests/e2e/generate-client/examples/ejected-generator/generators/.pristine/php.mjs diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index 0e6158c6be..c55875e5db 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -16,3 +16,5 @@ A custom generator can now declare its own options as a schema; publishers set t `eject-generator` now wires itself up: it records `@redocly/client-generator` in your `devDependencies` and adds the ejected file to `client.generators`, printing the snippet to add by hand only when the configuration file has a shape it won't edit blind. Generator compatibility is the package version under semver instead of a separate contract number: a generator declares the range it was written against with `requiresGenerator` (`^1.2.0`, `~1.2.0`, `>=1.2.0`, or an exact version), and a CLI outside that range says which version it ships and how to fix it. `GENERATOR_CONTRACT` is gone; ejected generators record the range for you. + +`eject-generator --update` no longer needs a committed `.pristine/` snapshot: the merge base is the version recorded in the ejected file's own header, fetched from the registry when it differs from the installed one. An existing `.pristine/` copy is still used as the base and can then be deleted. diff --git a/packages/cli/src/__tests__/eject-generator.test.ts b/packages/cli/src/__tests__/eject-generator.test.ts new file mode 100644 index 0000000000..6c60b55e42 --- /dev/null +++ b/packages/cli/src/__tests__/eject-generator.test.ts @@ -0,0 +1,28 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { packedAsset } from '../commands/eject-generator.js'; + +const clientGeneratorDir = resolve( + dirname(fileURLToPath(import.meta.url)), + '../../../client-generator' +); + +// `npm pack` on a directory runs that package's prepare script, so give it room. +vi.setConfig({ testTimeout: 180_000 }); + +describe('packedAsset', () => { + it('reads a generator out of a packed @redocly/client-generator', () => { + // A directory stands in for the version spec `--update` passes: same pack, same + // extraction, no registry needed to prove the mechanism. + const asset = packedAsset(clientGeneratorDir, 'php'); + expect(asset).toBe( + readFileSync(join(clientGeneratorDir, 'eject-assets/generators/php.mjs'), 'utf-8') + ); + }); + + it('returns undefined when the spec cannot be packed, so the caller can fall back', () => { + expect(packedAsset('@redocly/client-generator@0.0.0-does-not-exist', 'php')).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts index 742e95296b..72a9b4f262 100644 --- a/packages/cli/src/commands/eject-generator.ts +++ b/packages/cli/src/commands/eject-generator.ts @@ -1,11 +1,19 @@ import { HandledError, logger } from '@redocly/openapi-core'; import { spawnSync } from 'node:child_process'; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { ejectGeneratorTelemetry } from '../utils/generate-client-telemetry.js'; -import { version } from '../utils/package.js'; import { type CommandArgs } from '../wrapper.js'; export type EjectGeneratorCommandArgv = { @@ -99,19 +107,18 @@ function dropPointer(dir: string, ejected: string[]): void { /** 3-way merge via `git merge-file`; returns the merged text and the conflict count. */ function threeWayMerge( customized: string, - pristineBase: string, - pristineNew: string, - dir: string + base: string, + updated: string ): { merged: string; conflicts: number } { - const scratch = join(dir, '.pristine'); + const scratch = mkdtempSync(join(tmpdir(), 'redocly-eject-merge-')); const paths = { ours: join(scratch, '.merge-ours'), base: join(scratch, '.merge-base'), theirs: join(scratch, '.merge-theirs'), }; writeFileSync(paths.ours, customized, 'utf-8'); - writeFileSync(paths.base, pristineBase, 'utf-8'); - writeFileSync(paths.theirs, pristineNew, 'utf-8'); + writeFileSync(paths.base, base, 'utf-8'); + writeFileSync(paths.theirs, updated, 'utf-8'); const result = spawnSync( 'git', [ @@ -129,7 +136,7 @@ function threeWayMerge( ], { encoding: 'utf-8' } ); - for (const file of Object.values(paths)) rmSync(file, { force: true }); + rmSync(scratch, { recursive: true, force: true }); if (result.error || result.status === null || result.status < 0) { ejectGeneratorTelemetry.eject_generator_outcome = 'merge-tool-missing'; throw new HandledError( @@ -139,6 +146,38 @@ function threeWayMerge( return { merged: result.stdout, conflicts: result.status }; } +/** The toolkit version an ejected file records in its provenance header. */ +function recordedVersion(ejected: string): string | undefined { + return /Ejected from @redocly\/client-generator@(\S+)/.exec(ejected)?.[1]; +} + +/** + * The asset as a past version shipped it, taken from that version's package on the + * registry — the header records which version to ask for, so the merge base needs + * nothing committed. `spec` is anything npm can pack (a version spec; a directory in + * tests). Returns undefined when the fetch or the extraction fails, so the caller can + * fall back instead of merging against the wrong base. + */ +export function packedAsset(spec: string, name: string): string | undefined { + const scratch = mkdtempSync(join(tmpdir(), 'redocly-eject-base-')); + try { + const packed = spawnSync('npm', ['pack', spec, '--pack-destination', scratch], { + encoding: 'utf-8', + }); + if (packed.status !== 0) return undefined; + const tarball = readdirSync(scratch).find((file) => file.endsWith('.tgz')); + if (tarball === undefined) return undefined; + const member = `package/eject-assets/generators/${name}.mjs`; + const extracted = spawnSync('tar', ['-xzf', join(scratch, tarball), '-C', scratch, member], { + encoding: 'utf-8', + }); + if (extracted.status !== 0) return undefined; + return readFileSync(join(scratch, member), 'utf-8'); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } +} + /** 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`))); @@ -149,7 +188,7 @@ function ejectedIn(dir: string): string[] { * imports the authoring toolkit from it. Installing stays the user's call; this only makes * the requirement part of the project so a fresh clone or CI gets it. Returns what happened. */ -function wireDependency(): 'added' | 'present' | 'no-package-json' { +function wireDependency(toolkitVersion: string): 'added' | 'present' | 'no-package-json' { const manifestPath = join(process.cwd(), 'package.json'); if (!existsSync(manifestPath)) return 'no-package-json'; const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as { @@ -162,7 +201,10 @@ function wireDependency(): 'added' | 'present' | 'no-package-json' { ) { return 'present'; } - const devDependencies = { ...manifest.devDependencies, [TOOLKIT_PACKAGE]: `^${version}` }; + const devDependencies = { + ...manifest.devDependencies, + [TOOLKIT_PACKAGE]: `^${toolkitVersion}`, + }; manifest.devDependencies = Object.fromEntries( Object.entries(devDependencies).sort(([left], [right]) => left.localeCompare(right)) ); @@ -244,27 +286,48 @@ export const handleEjectGenerator = async ({ const assetsDir = ejectAssetsDir(); const asset = readFileSync(join(assetsDir, 'generators', `${name}.mjs`), 'utf-8'); + // The version that matters is the TOOLKIT's (what the ejected file records and imports), + // not the CLI's — they version independently. + const { GENERATOR_VERSION: toolkitVersion } = await import('@redocly/client-generator'); const dir = resolve(argv.dir ?? './generators'); - const pristineDir = join(dir, '.pristine'); const target = join(dir, `${name}.mjs`); - const pristine = join(pristineDir, `${name}.mjs`); + // Ejects before the base moved to the registry left a snapshot behind; it still works + // as the base, which keeps `--update` offline for anyone mid-migration. + const legacyBase = join(dir, '.pristine', `${name}.mjs`); const printedTarget = relative(process.cwd(), target) || target; if (argv.update) { - if (!existsSync(target) || !existsSync(pristine)) { - ejectGeneratorTelemetry.eject_generator_outcome = 'missing-pristine'; + if (!existsSync(target)) { + ejectGeneratorTelemetry.eject_generator_outcome = 'missing-target'; throw new HandledError( - `\n❌ Nothing to update: ${printedTarget} (and its pristine snapshot) must exist. Eject first.\n` + `\n❌ Nothing to update: ${printedTarget} does not exist. Eject first.\n` ); } - const { merged, conflicts } = threeWayMerge( - readFileSync(target, 'utf-8'), - readFileSync(pristine, 'utf-8'), - asset, - dir - ); + const customized = readFileSync(target, 'utf-8'); + const from = recordedVersion(customized); + const base = existsSync(legacyBase) + ? readFileSync(legacyBase, 'utf-8') + : from === toolkitVersion + ? asset + : from === undefined + ? undefined + : packedAsset(`${TOOLKIT_PACKAGE}@${from}`, name); + if (base === undefined) { + ejectGeneratorTelemetry.eject_generator_outcome = 'missing-base'; + const sideBySide = `${target}.new`; + writeFileSync(sideBySide, asset, 'utf-8'); + throw new HandledError( + `\n❌ Could not read the version this file was ejected from (${from ?? 'not recorded in its header'}), so there is no merge base.\n` + + ` The current generator is written to ${relative(process.cwd(), sideBySide)} — diff it against your copy and merge by hand.\n` + ); + } + const { merged, conflicts } = threeWayMerge(customized, base, asset); writeFileSync(target, merged, 'utf-8'); - writeFileSync(pristine, asset, 'utf-8'); + if (existsSync(legacyBase)) { + logger.info( + `Used ${relative(process.cwd(), legacyBase)} as the merge base. Later updates read the version from the file's header, so you can delete that .pristine directory.\n` + ); + } dropSkill('client-generators', assetsDir); dropSkill(`${name}-generator`, assetsDir); dropPointer(dir, ejectedIn(dir)); @@ -275,7 +338,7 @@ export const handleEjectGenerator = async ({ `Updated ${printedTarget} with ${conflicts} conflict(s) — resolve the <<<<<<< markers, then regenerate.\n` ); } else { - logger.info(`Updated ${printedTarget} cleanly; pristine snapshot refreshed.\n`); + logger.info(`Updated ${printedTarget} cleanly.\n`); } return; } @@ -286,18 +349,17 @@ export const handleEjectGenerator = async ({ `\n❌ ${printedTarget} already exists. Use --update to merge the newer version in, or --force to overwrite.\n` ); } - mkdirSync(pristineDir, { recursive: true }); + mkdirSync(dir, { recursive: true }); writeFileSync(target, asset, 'utf-8'); - writeFileSync(pristine, asset, 'utf-8'); const authoringSkill = dropSkill('client-generators', assetsDir); const designSkill = dropSkill(`${name}-generator`, assetsDir); dropPointer(dir, ejectedIn(dir)); ejectGeneratorTelemetry.eject_generator_outcome = 'success'; const configEntry = `./${relative(process.cwd(), target).split('\\').join('/')}`; - const dependency = wireDependency(); + const dependency = wireDependency(toolkitVersion); const wired = wireConfig(config.configPath, configEntry); logger.info( - `Ejected the "${name}" generator to ${printedTarget} (pristine snapshot committed alongside).\n` + + `Ejected the "${name}" generator to ${printedTarget}.\n` + (dependency === 'added' ? `Added ${TOOLKIT_PACKAGE} to devDependencies (the ejected file imports its toolkit) — run your installer.\n` : dependency === 'no-package-json' diff --git a/tests/e2e/generate-client/eject.test.ts b/tests/e2e/generate-client/eject.test.ts index 9268ec359d..832cef927b 100644 --- a/tests/e2e/generate-client/eject.test.ts +++ b/tests/e2e/generate-client/eject.test.ts @@ -49,7 +49,8 @@ describe('eject-generator (end-to-end)', () => { const eject = run(project, ['eject-generator', 'php']); expect(eject.status, eject.stderr).toBe(0); expect(existsSync(join(project, 'generators/php.mjs'))).toBe(true); - expect(existsSync(join(project, 'generators/.pristine/php.mjs'))).toBe(true); + // Nothing extra is committed: the merge base comes from the version in the header. + expect(existsSync(join(project, 'generators/.pristine'))).toBe(false); // The design ships where an agent auto-loads it, with skill frontmatter. const design = readFileSync(join(project, '.claude/skills/php-generator/SKILL.md'), 'utf-8'); @@ -84,7 +85,11 @@ describe('eject-generator (end-to-end)', () => { expect(eject.status, eject.stderr).toBe(0); const pkg = JSON.parse(readFileSync(join(wired, 'package.json'), 'utf-8')); - expect(pkg.devDependencies['@redocly/client-generator']).toMatch(/^\^\d+\./); + // The recorded range is the TOOLKIT's version — the package the ejected file imports. + const toolkitVersion = JSON.parse( + readFileSync(join(repoRoot, 'packages/client-generator/package.json'), 'utf-8') + ).version; + expect(pkg.devDependencies['@redocly/client-generator']).toBe(`^${toolkitVersion}`); expect(readFileSync(join(wired, 'redocly.yaml'), 'utf-8')).toBe( 'extends: []\nclient:\n generators:\n - sdk\n - ./generators/go.mjs\n' ); @@ -150,19 +155,21 @@ describe('eject-generator (end-to-end)', () => { '// my local customization' ); - // Diverge the same first line in the pristine base and the user copy: a true conflict. - for (const [file, line] of [ - ['generators/.pristine/php.mjs', '// OLD pristine line'], - ['generators/php.mjs', '// USER edited line'], - ] as const) { - const path = join(project, file); - const lines = readFileSync(path, 'utf-8').split('\n'); - lines[0] = line; - writeFileSync(path, lines.join('\n'), 'utf-8'); - } + // A `.pristine/` copy from an older CLI still works as the base, and says it can go. + const legacy = join(project, 'generators/.pristine'); + mkdirSync(legacy, { recursive: true }); + const ejected = join(project, 'generators/php.mjs'); + const base = readFileSync(ejected, 'utf-8').split('\n'); + const mine = [...base]; + base[0] = '// OLD base line'; + mine[0] = '// USER edited line'; + writeFileSync(join(legacy, 'php.mjs'), base.join('\n'), 'utf-8'); + writeFileSync(ejected, mine.join('\n'), 'utf-8'); const conflicted = run(project, ['eject-generator', 'php', '--update']); expect(conflicted.status, conflicted.stderr).toBe(0); - expect(conflicted.stderr + conflicted.stdout).toContain('conflict'); - expect(readFileSync(join(project, 'generators/php.mjs'), 'utf-8')).toContain('<<<<<<<'); + const output = conflicted.stderr + conflicted.stdout; + expect(output).toContain('conflict'); + expect(output).toContain('.pristine'); + expect(readFileSync(ejected, 'utf-8')).toContain('<<<<<<<'); }, 60_000); }); 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 0c8fc535af..1885959c57 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 @@ -72,10 +72,11 @@ Rules: output is deterministic (same description → same bytes); never add dependencies to the generated client; **never hand-edit generated output** — edit this generator and regenerate. Emitted file paths must stay inside the `--output` directory (subdirectories are fine) — escapes are rejected. -Optionally declare `contract` (the `GENERATOR_CONTRACT` number exported by -`@redocly/client-generator`): a CLI whose contract differs then fails with the -fix path instead of feeding your generator an unexpected model shape. Ejected -generators carry it automatically. +Optionally declare `requiresGenerator` — the `@redocly/client-generator` version +range you wrote this against (`'^1.2.0'`, `'~1.2.0'`, `'>=1.2.0'`, or an exact +version). A CLI outside the range then fails with the fix path instead of feeding +your generator an unexpected model shape. Ejected generators carry it +automatically; hand-written ones without it are taken as current. ## The model (IR) diff --git a/tests/e2e/generate-client/examples/ejected-generator/README.md b/tests/e2e/generate-client/examples/ejected-generator/README.md index edd07ec39a..de3a1984db 100644 --- a/tests/e2e/generate-client/examples/ejected-generator/README.md +++ b/tests/e2e/generate-client/examples/ejected-generator/README.md @@ -12,6 +12,7 @@ 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. -`generators/.pristine/php.mjs` (committed, as it should be in your repo too) is the merge base: `npm run update-generator` three-way-merges a newer generator version into the customized copy — clean hunks apply silently, real conflicts get standard markers. +`npm run update-generator` three-way-merges a newer generator version into this customized copy — clean hunks apply silently, real conflicts get standard `<<<<<<<` markers. +The merge base is the version recorded in the file's own header, so there is nothing extra to commit or keep in sync. This example started from `redocly eject-generator php`; run that in your own repo to begin. The ejected file imports the authoring toolkit and the embedded runtime from `@redocly/client-generator`, so runtime fixes still arrive with plain `npm update` — no merge needed. diff --git a/tests/e2e/generate-client/examples/ejected-generator/generators/.pristine/php.mjs b/tests/e2e/generate-client/examples/ejected-generator/generators/.pristine/php.mjs deleted file mode 100644 index f68db5e5f8..0000000000 --- a/tests/e2e/generate-client/examples/ejected-generator/generators/.pristine/php.mjs +++ /dev/null @@ -1,576 +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.'); - writer.blank(); - writer.line('declare(strict_types=1);'); - writer.blank(); - writer.line(`namespace ${namespace};`); - writer.blank(); - writer.line(renderPhpModels(model)); - writer.line('// ─── Embedded runtime (@redocly/client-generator php runtime) ───'); - writer.line(stripPhpHeader(PHP_RUNTIME_SOURCE)); - writer.blank(); - const operations = model.services.flatMap((service) => service.operations); - const paginationRules = new Map(); - for (const op of operations) { - const rule = paginationRuleFor(op, emit.pagination); - if (rule !== undefined) - paginationRules.set(op.name, rule); - } - writer.block('const OPERATIONS = [', () => { - for (const op of operations) { - const id = op.specName ?? op.name; - const security = phpSecurityLiteral(op, model); - const rule = paginationRules.get(op.name); - const fields = [ - `'id' => ${phpString(id)}`, - `'method' => ${phpString(op.method.toUpperCase())}`, - `'path' => ${phpString(op.path)}`, - ...(security !== undefined ? [`'security' => ${security}`] : []), - ...(rule !== undefined ? [`'pagination' => ${phpPaginationLiteral(rule)}`] : []), - ]; - writer.line(`${phpString(id)} => [${fields.join(', ')}],`); - } - }, '];'); - writer.blank(); - writeDocComment(writer, 'Client', `Client for ${model.title} (${model.version}).`); - writer.block('final class Client', () => { }, ''); - writer.block('{', () => { - writer.block('public function __construct(private Config $config)', () => { }, ''); - writer.block('{', () => { - writer.block("if ($this->config->serverUrl === '') {", () => { - writer.line(`$this->config->serverUrl = ${phpString(model.serverUrl ?? '')};`); - }, '}'); - }, '}'); - writer.blank(); - for (const op of operations) { - writePhpMethod(writer, op, model); - const rule = paginationRules.get(op.name); - if (rule === undefined) - continue; - const success = successSchema(op); - const pageHydration = success === undefined ? undefined : hydration(success, '$page', model); - // Resolve the items ARRAY, then take its raw element, so a `ref` element - // keeps its class name (a deref'd result would hydrate as plain data). - const itemsArray = success !== undefined && rule.items !== undefined - ? schemaAtPointer(success, rule.items, model) - : undefined; - const element = itemsArray?.kind === 'array' ? itemsArray.items : undefined; - const itemHydration = element === undefined ? undefined : hydration(element, '$item', model); - writePhpPaginationWrappers(writer, op, model, pageHydration, itemHydration, rule.items); - } - }, '}'); - return [{ path: outputPath.replace(/\.[^.\\/]+$/, '.php'), content: writer.toString() }]; -}; -/** One idiomatic PHP call per operation — feeds `x-codeSamples` for docs. */ -export function phpSample(op, ctx) { - const args = [ - ...op.pathParams.map((param) => `${phpString(`<${propertyName(param.name)}>`)}`), - ...(op.requestBody ? ['$body'] : []), - ...(op.queryParams.length > 0 - ? [`${propertyName(op.queryParams[0].name)}: ${phpString('')}`] - : []), - ]; - const namespace = identifierFor(ctx.model.title, { style: 'pascal', reserved: PHP }); - return { - lang: 'php', - label: 'PHP SDK', - source: `use ${namespace}\\{Client, Config};\n\n$client = new Client(new Config());\n$result = $client->${methodName(op)}(${args.join(', ')});\n`, - }; -} - -export default { - name: 'php', - run: phpGenerator, - sample: phpSample, -}; From 7ee3ab355cdd6fb9ec47c32e0e0efdb95bdd237d Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Thu, 6 Aug 2026 08:36:46 +0300 Subject: [PATCH 107/211] feat: make every built-in generator ejectable, bundling the TypeScript ones --- .changeset/agent-friendly-generators.md | 2 + docs/@v2/commands/eject-generator.md | 6 +- .../commands/eject-generator.test.ts | 11 +- packages/cli/src/commands/eject-generator.ts | 68 +++++---- .../skills/cli-generator/SKILL.md | 68 +++++++++ .../skills/mock-generator/SKILL.md | 45 ++++++ .../skills/sdk-generator/SKILL.md | 65 ++++++++ .../skills/swr-generator/SKILL.md | 44 ++++++ .../skills/tanstack-query-generator/SKILL.md | 48 ++++++ .../skills/transformers-generator/SKILL.md | 43 ++++++ .../skills/zod-generator/SKILL.md | 49 ++++++ .../scripts/generate-eject-assets.mjs | 144 ++++++++++++++---- .../__tests__/generator-skills.test.ts | 37 ++++- .../src/generators/cli/AGENTS.md | 17 +-- .../src/generators/mock/AGENTS.md | 16 +- .../src/generators/sdk/AGENTS.md | 25 ++- .../src/generators/swr/AGENTS.md | 16 +- .../src/generators/tanstack-query/AGENTS.md | 18 +-- .../src/generators/transformers/AGENTS.md | 16 +- .../src/generators/zod/AGENTS.md | 16 +- tests/e2e/generate-client/eject.test.ts | 41 ++++- 21 files changed, 636 insertions(+), 159 deletions(-) create mode 100644 packages/client-generator/eject-assets/skills/cli-generator/SKILL.md create mode 100644 packages/client-generator/eject-assets/skills/mock-generator/SKILL.md create mode 100644 packages/client-generator/eject-assets/skills/sdk-generator/SKILL.md create mode 100644 packages/client-generator/eject-assets/skills/swr-generator/SKILL.md create mode 100644 packages/client-generator/eject-assets/skills/tanstack-query-generator/SKILL.md create mode 100644 packages/client-generator/eject-assets/skills/transformers-generator/SKILL.md create mode 100644 packages/client-generator/eject-assets/skills/zod-generator/SKILL.md diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index c55875e5db..8f92d21d8e 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -18,3 +18,5 @@ A custom generator can now declare its own options as a schema; publishers set t Generator compatibility is the package version under semver instead of a separate contract number: a generator declares the range it was written against with `requiresGenerator` (`^1.2.0`, `~1.2.0`, `>=1.2.0`, or an exact version), and a CLI outside that range says which version it ships and how to fix it. `GENERATOR_CONTRACT` is gone; ejected generators record the range for you. `eject-generator --update` no longer needs a committed `.pristine/` snapshot: the merge base is the version recorded in the ejected file's own header, fetched from the registry when it differs from the installed one. An existing `.pristine/` copy is still used as the base and can then be deleted. + +Every built-in generator is now ejectable, not just the language SDKs: a TypeScript generator (`sdk`, `zod`, `mock`, `cli`, `swr`, `tanstack-query`, `transformers`) ships bundled with the emitters it uses — one unminified `.mjs` you own that produces byte-identical output until you change it. The `tanstack-query-vue`/`-svelte`/`-solid` variants point at `tanstack-query`, whose framework is one argument in the ejected file. diff --git a/docs/@v2/commands/eject-generator.md b/docs/@v2/commands/eject-generator.md index 51db561d9d..1217f4b20b 100644 --- a/docs/@v2/commands/eject-generator.md +++ b/docs/@v2/commands/eject-generator.md @@ -6,6 +6,7 @@ The `eject-generator` command vendors a built-in client generator into your repo Your agent (or you) edits the generator, `redocly generate-client` rebuilds the client, and next week's spec change regenerates with the customization intact. Every built-in generator can be ejected: the language SDKs (`python`, `go`, `php`), the TypeScript `sdk`, and the satellites (`zod`, `mock`, `cli`, `swr`, `tanstack-query`, `transformers`). +The `tanstack-query-vue`, `-svelte`, and `-solid` variants are the same generator with one argument changed, so eject `tanstack-query` and set the framework in your copy. ## Usage @@ -29,7 +30,10 @@ redocly eject-generator php --force Ejecting writes two things: -- `/.mjs` — the generator itself, as plain ESM you own. It imports the authoring toolkit from `@redocly/client-generator` and contains everything else it needs, so it runs standalone. +- `/.mjs` — the generator itself, as plain ESM you own, containing everything it needs to run standalone. + A language generator (`python`, `go`, `php`) is one self-contained file, so you get its source as we wrote it. + A TypeScript generator is a thin entry over shared emitters, so you get it bundled with those emitters: unminified, with a comment marking each source module. + Either way it imports the authoring toolkit from `@redocly/client-generator`, and a bundled one also imports `logger` and `isPlainObject` from `@redocly/openapi-core` — a dependency of the toolkit, worth adding explicitly if your package manager doesn't hoist. - `.claude/skills/-generator/SKILL.md` — the generator's design as an agent skill: the decisions its code implements, and the loop to follow when changing it (state the change in the skill, then make the code match). Coding agents load skills automatically, so your agent starts from the design instead of reverse-engineering the code. diff --git a/packages/cli/src/__tests__/commands/eject-generator.test.ts b/packages/cli/src/__tests__/commands/eject-generator.test.ts index 8c7cb529e2..64933deaae 100644 --- a/packages/cli/src/__tests__/commands/eject-generator.test.ts +++ b/packages/cli/src/__tests__/commands/eject-generator.test.ts @@ -16,11 +16,16 @@ function reset() { describe('eject telemetry (coarse categories only)', () => { beforeEach(reset); - it('sdk guidance records the allowlisted name and a guidance action', async () => { - await handleEjectGenerator({ ...baseArgs, argv: { generator: 'sdk' } } as CommandArgs); + it('a framework variant records the allowlisted name and a guidance action', async () => { + // Every generator ejects now; only the tanstack-query framework variants are guidance, + // since they are that generator with one argument changed. + await handleEjectGenerator({ + ...baseArgs, + argv: { generator: 'tanstack-query-vue' }, + } as CommandArgs); expect(ejectGeneratorTelemetry).toEqual({ eject_generator_action: 'guidance', - eject_generator_name: 'sdk', + eject_generator_name: 'tanstack-query-vue', eject_generator_outcome: 'success', }); }); diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts index 72a9b4f262..08e106b9c5 100644 --- a/packages/cli/src/commands/eject-generator.ts +++ b/packages/cli/src/commands/eject-generator.ts @@ -24,23 +24,34 @@ export type EjectGeneratorCommandArgv = { update?: boolean; }; -/** The neutral-toolkit generators shipped as vendorable assets. */ -const EJECTABLE = new Set(['python', 'go', 'php']); -const TS_BUILTINS = new Set([ +/** Every built-in generator ships as a vendorable asset. */ +const EJECTABLE = new Set([ + 'python', + 'go', + 'php', 'sdk', 'zod', - 'tanstack-query', - 'tanstack-query-vue', - 'tanstack-query-svelte', - 'tanstack-query-solid', + 'mock', 'swr', + 'tanstack-query', 'transformers', - 'mock', 'cli', ]); -/** The package an ejected generator imports its toolkit from; recorded as a devDependency. */ +/** + * The tanstack-query framework variants share one implementation — the framework is a + * single argument in the ejected file — so they point at the base generator instead of + * shipping four near-identical bundles. + */ +const FRAMEWORK_VARIANTS = new Map([ + ['tanstack-query-vue', 'vue'], + ['tanstack-query-svelte', 'svelte'], + ['tanstack-query-solid', 'solid'], +]); + +/** The packages an ejected generator imports; recorded as devDependencies. */ const TOOLKIT_PACKAGE = '@redocly/client-generator'; +const CORE_PACKAGE = '@redocly/openapi-core'; const AGENTS_BEGIN = ''; @@ -188,23 +199,19 @@ function ejectedIn(dir: string): string[] { * imports the authoring toolkit from it. Installing stays the user's call; this only makes * the requirement part of the project so a fresh clone or CI gets it. Returns what happened. */ -function wireDependency(toolkitVersion: string): 'added' | 'present' | 'no-package-json' { +function wireDependency(packages: Record): 'added' | 'present' | 'no-package-json' { const manifestPath = join(process.cwd(), 'package.json'); if (!existsSync(manifestPath)) return 'no-package-json'; const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as { dependencies?: Record; devDependencies?: Record; }; - if ( - manifest.dependencies?.[TOOLKIT_PACKAGE] !== undefined || - manifest.devDependencies?.[TOOLKIT_PACKAGE] !== undefined - ) { - return 'present'; - } - const devDependencies = { - ...manifest.devDependencies, - [TOOLKIT_PACKAGE]: `^${toolkitVersion}`, - }; + const missing = Object.entries(packages).filter( + ([name]) => + manifest.dependencies?.[name] === undefined && manifest.devDependencies?.[name] === undefined + ); + if (missing.length === 0) return 'present'; + const devDependencies = { ...manifest.devDependencies, ...Object.fromEntries(missing) }; manifest.devDependencies = Object.fromEntries( Object.entries(devDependencies).sort(([left], [right]) => left.localeCompare(right)) ); @@ -263,17 +270,18 @@ export const handleEjectGenerator = async ({ // Coarse usage telemetry: our command action, an ALLOWLISTED built-in name, and the // outcome category — never user paths, file contents, or user-chosen names. ejectGeneratorTelemetry.eject_generator_action = argv.update ? 'update' : 'eject'; - if (EJECTABLE.has(name) || TS_BUILTINS.has(name)) { + if (EJECTABLE.has(name) || FRAMEWORK_VARIANTS.has(name)) { ejectGeneratorTelemetry.eject_generator_name = name; } - if (TS_BUILTINS.has(name)) { + const framework = FRAMEWORK_VARIANTS.get(name); + if (framework !== undefined) { ejectGeneratorTelemetry.eject_generator_action = 'guidance'; ejectGeneratorTelemetry.eject_generator_outcome = 'success'; logger.info( - `\nThe "${name}" generator is not ejectable — it is TypeScript-toolkit based.\n` + - `Customize its output instead: publisher defaults via \`client.setup\`, behavior via middleware,\n` + - `and options in \`redocly.yaml\` (see the "Customize client generation" guide).\n` + - `Ejectable generators: ${[...EJECTABLE].join(', ')}.\n` + `\nThe "${name}" generator is the "tanstack-query" generator with one argument changed.\n` + + `Eject that one and set the framework in your copy's default export:\n\n` + + ` redocly eject-generator tanstack-query\n` + + ` # then in generators/tanstack-query.mjs: run: tanstackQueryGenerator('${framework}')\n` ); return; } @@ -356,7 +364,10 @@ export const handleEjectGenerator = async ({ dropPointer(dir, ejectedIn(dir)); ejectGeneratorTelemetry.eject_generator_outcome = 'success'; const configEntry = `./${relative(process.cwd(), target).split('\\').join('/')}`; - const dependency = wireDependency(toolkitVersion); + const dependency = wireDependency({ [TOOLKIT_PACKAGE]: `^${toolkitVersion}` }); + // A bundled TypeScript generator also imports `logger`/`isPlainObject` from core, which + // the toolkit depends on — worth saying out loud for a package manager that doesn't hoist. + const needsCore = asset.includes(`from "${CORE_PACKAGE}"`); const wired = wireConfig(config.configPath, configEntry); logger.info( `Ejected the "${name}" generator to ${printedTarget}.\n` + @@ -365,6 +376,9 @@ export const handleEjectGenerator = async ({ : dependency === 'no-package-json' ? `The ejected file imports its toolkit from ${TOOLKIT_PACKAGE} — install it: npm install --save-dev ${TOOLKIT_PACKAGE}\n` : '') + + (needsCore + ? `It also imports ${CORE_PACKAGE} (a dependency of the toolkit) — add it explicitly if your package manager does not hoist.\n` + : '') + (wired ? `Added it to client.generators in ${relative(process.cwd(), config.configPath!)} — the path entry takes over the built-in name.\n` : `Point your config at the file — the path entry takes over the built-in name:\n\n` + diff --git a/packages/client-generator/eject-assets/skills/cli-generator/SKILL.md b/packages/client-generator/eject-assets/skills/cli-generator/SKILL.md new file mode 100644 index 0000000000..64cde6c2f3 --- /dev/null +++ b/packages/client-generator/eject-assets/skills/cli-generator/SKILL.md @@ -0,0 +1,68 @@ +--- +name: cli-generator +description: Design of the ejected Redocly `cli` client generator. Read it, and update it, before changing generators/cli.mjs. +--- + +# The `cli` generator — its skill + +This file is the DESIGN of your ejected `cli` generator (`generators/cli.mjs`): +**to change the generator, edit this skill first, then make the code match it** — a diff +to `generators/cli.mjs` that has no covering sentence here is incomplete. + +## What it emits + +A bin-ready `.cli.ts`: one command per operation over the sdk's instance client, +with `--help`, a `schema ` introspection command, and `--dry-run`. + +## Design decisions that must hold + +- **Argument shape:** path params positional, query params typed `--kebab-name` flags, + JSON bodies via `--json '' | @file | @-` (stdin). +- **Help is the whole interface.** A flag that exists but isn't in `--help` doesn't exist + to the user, so the top-level help carries a `Global flags:` section (`--server-url`, + `--format`, `--dry-run`, `--page-all`, `--output`, `--token`, `--json`) plus the + credential environment variables. Descriptions are collapsed to ONE line — an OpenAPI + description with newlines otherwise breaks the alignment of every following flag. The + footer names the form that actually works for a grouped API + (` --help`). +- **Commands are addressable the way a shell allows.** A group slug is kebab-cased so a + multi-word OpenAPI tag can be typed without quoting, while help shows the original tag. + A bare operationId resolves to its grouped command when unambiguous. +- **Exit codes are a contract:** 0 ok, 1 API error, 2 auth, 3 validation, 4 usage. + Errors print ONE JSON object to stderr so stdout stays pipeable. +- **The bin name is a command name, not a filename.** It defaults to the output stem with + dots and other non-word characters folded to `-` (`openapi.client` → `openapi-client`), + because the stem follows the TypeScript file convention and a usage line reading + `openapi.client orders …` looks like a path. `client.binName` overrides it. +- **Credentials come from the environment** (a stem-derived prefix, e.g. + `CLIENT_TOKEN`) or explicit flags; `--dry-run` prints the prepared request with + credentials REDACTED. +- **Validation is on by default.** The generator declares `requires: ['sdk', 'zod']` and + the pipeline pulls prerequisites in automatically, so `--generator cli` alone produces a + validating CLI — a user shouldn't have to know which other generator provides it. The + consequence is a zod peer dependency at run time, which the docs state. +- Throw-mode only — the exit-code mapping reads thrown `ApiError`s. +- **Runs under `node --experimental-strip-types` with no build step**, including the + modules it imports (the sdk and the zod module). Anything emitted must be erasable + TypeScript; a parameter property anywhere in that import graph breaks the zero-build + runner. + +## Emitters that implement it + +`emitters/cli.ts` (commands + module), plus the sdk's operation types. + +## Ejecting it + +`redocly eject-generator cli` ships this generator BUNDLED with the emitters it uses — one +`.mjs` you own, importing `@redocly/client-generator` and `@redocly/openapi-core`. Change +the command surface, the help layout, or the exit-code mapping, and regenerate. The exit +codes are a contract for scripts, so change them only deliberately. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Make `generators/cli.mjs` match it. +3. Run `redocly generate-client` and inspect the `git diff` of the generated output — + generated files are never hand-edited. + +Newer built-in versions merge in with `redocly eject-generator cli --update`. diff --git a/packages/client-generator/eject-assets/skills/mock-generator/SKILL.md b/packages/client-generator/eject-assets/skills/mock-generator/SKILL.md new file mode 100644 index 0000000000..b46113901b --- /dev/null +++ b/packages/client-generator/eject-assets/skills/mock-generator/SKILL.md @@ -0,0 +1,45 @@ +--- +name: mock-generator +description: Design of the ejected Redocly `mock` client generator. Read it, and update it, before changing generators/mock.mjs. +--- + +# The `mock` generator — its skill + +This file is the DESIGN of your ejected `mock` generator (`generators/mock.mjs`): +**to change the generator, edit this skill first, then make the code match it** — a diff +to `generators/mock.mjs` that has no covering sentence here is incomplete. + +## What it emits + +A standalone MSW module: `create()` data factories, `Handler()` / +`ErrorHandler(status, body?)` request handlers, and a `handlers` array. + +## Design decisions that must hold + +- **Two data modes:** `mockData: static` bakes deterministic samples from the schema + (examples/defaults first); `faker` emits `faker.*` calls with a seed (`mockSeed`) so + runs are reproducible. +- **Interpolated identifiers are gated** (`codeIdent`): an operation name or method + reaching a code position is validated, never trusted, even though the pipeline + sanitizes upstream. +- Handlers are opt-in overrides: `ErrorHandler` is NOT in `handlers`. +- The module references the sdk's TYPES only — never its runtime. + +## Emitters that implement it + +`emitters/mock.ts`, `mock-value.ts` (data trees), `faker.ts`, `sample.ts`. + +## Ejecting it + +`redocly eject-generator mock` ships this generator BUNDLED with the emitter it uses — one +small `.mjs` you own, importing `@redocly/client-generator` and `@redocly/openapi-core`. +Change the data strategy, the handler shape, or the factory surface, and regenerate. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Make `generators/mock.mjs` match it. +3. Run `redocly generate-client` and inspect the `git diff` of the generated output — + generated files are never hand-edited. + +Newer built-in versions merge in with `redocly eject-generator mock --update`. diff --git a/packages/client-generator/eject-assets/skills/sdk-generator/SKILL.md b/packages/client-generator/eject-assets/skills/sdk-generator/SKILL.md new file mode 100644 index 0000000000..af31b929b8 --- /dev/null +++ b/packages/client-generator/eject-assets/skills/sdk-generator/SKILL.md @@ -0,0 +1,65 @@ +--- +name: sdk-generator +description: Design of the ejected Redocly `sdk` client generator. Read it, and update it, before changing generators/sdk.mjs. +--- + +# The `sdk` generator — its skill + +This file is the DESIGN of your ejected `sdk` generator (`generators/sdk.mjs`): +**to change the generator, edit this skill first, then make the code match it** — a diff +to `generators/sdk.mjs` that has no covering sentence here is incomplete. + +## What it emits + +The typed TypeScript client itself: model types with JSDoc, type guards, the `Ops` +type map, the `OPERATIONS` descriptor table, a `client` instance, flat call sugar, +and either the embedded runtime (`runtime: inline`) or imports from +`@redocly/client-generator` (`runtime: package`). + +## Design decisions that must hold + +- **Descriptor-driven:** generated code is DATA (`OPERATIONS` + `Ops`) plus wiring; + request behavior lives in the runtime, never in per-operation code. + `satisfies Record` is the version-skew guard. +- **`single` vs `split`:** split derives `.schemas.ts` (types, enums, guards) and + an entry that `export *`s it; the entry type-imports only the schema names it + references (`collectEntrySchemaRefs`). +- **Zero runtime dependencies.** `Date`, `Blob`, `fetch` — nothing else. +- **Names are collision-safe:** `packageIdents` seeds every reserved wiring name before + any operation is sanitized, so renames are deterministic (`configure` → `configure_2`). + A rename becomes part of the SDK's public API, so the warning must say WHICH cause it + is and what the publisher can do: a duplicate `operationId` in the description (fix the + description — the only real fix), a name that isn't a valid identifier, or a clash with + a name the generated module already declares. A vague "collides or is invalid" message + leaves the publisher unable to act. +- **Throw mode returns the body**; `{ envelope: true }` opts into + `{ data, headers, response }` with typed declared headers. Result mode returns + `{ data, error, response }` and ignores `envelope`. + +## Emitters that implement it + +`emitters/client-assembly.ts` (orchestration), `render-client.ts` (Ops, aliases, flat +sugar), `descriptor.ts`, `ts-type.ts`/`ts-literal.ts` (type + data text), `sse.ts`, +`pagination.ts`, `response-headers.ts`, `inline-runtime.ts`, `setup-bake.ts`. + +## Ejecting it + +`redocly eject-generator sdk` ships this generator BUNDLED with the emitters it uses — +one `.mjs` you own, unminified, with a comment marking each source module. It imports +only `@redocly/client-generator` (the toolkit and the embedded runtime) and +`@redocly/openapi-core` (`logger`, `isPlainObject`), so runtime fixes still arrive by +`npm update`. + +It is the largest of them (the whole client emitter plus the runtime it embeds), so reach +for the smaller paths first when they fit: `client.setup` bakes publisher defaults into the +generated client, and middleware or `configure()` change behavior at run time rather than +generation time. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Make `generators/sdk.mjs` match it. +3. Run `redocly generate-client` and inspect the `git diff` of the generated output — + generated files are never hand-edited. + +Newer built-in versions merge in with `redocly eject-generator sdk --update`. diff --git a/packages/client-generator/eject-assets/skills/swr-generator/SKILL.md b/packages/client-generator/eject-assets/skills/swr-generator/SKILL.md new file mode 100644 index 0000000000..62ca0f1e99 --- /dev/null +++ b/packages/client-generator/eject-assets/skills/swr-generator/SKILL.md @@ -0,0 +1,44 @@ +--- +name: swr-generator +description: Design of the ejected Redocly `swr` client generator. Read it, and update it, before changing generators/swr.mjs. +--- + +# The `swr` generator — its skill + +This file is the DESIGN of your ejected `swr` generator (`generators/swr.mjs`): +**to change the generator, edit this skill first, then make the code match it** — a diff +to `generators/swr.mjs` that has no covering sentence here is incomplete. + +## What it emits + +React SWR hooks over the sdk's exported operation functions: `use()` with a +`Key()` key factory for queries, `useSWRMutation` for mutations. + +## Design decisions that must hold + +- **Wraps the sdk's functions** — it never re-implements requests, so it requires `sdk` + and is throw-mode only. +- **Keys are exported factories** so consumers can invalidate precisely. +- **`envelope` is excluded** from hook options (`Omit`) and + stripped from the forwarded call: cached data is always the plain body. +- **Skips what it cannot wrap** — SSE operations and `Variables` name collisions — + with a warning naming each one, never silently. + +## Emitters that implement it + +`emitters/swr.ts`, `wrapper-support.ts` (shared wrappable-operation policy). + +## Ejecting it + +`redocly eject-generator swr` ships this generator BUNDLED with the emitter it uses — one +small `.mjs` you own, importing `@redocly/client-generator` and `@redocly/openapi-core`. +Change the hook shape or the key strategy, and regenerate. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Make `generators/swr.mjs` match it. +3. Run `redocly generate-client` and inspect the `git diff` of the generated output — + generated files are never hand-edited. + +Newer built-in versions merge in with `redocly eject-generator swr --update`. diff --git a/packages/client-generator/eject-assets/skills/tanstack-query-generator/SKILL.md b/packages/client-generator/eject-assets/skills/tanstack-query-generator/SKILL.md new file mode 100644 index 0000000000..5c2c6bbfc4 --- /dev/null +++ b/packages/client-generator/eject-assets/skills/tanstack-query-generator/SKILL.md @@ -0,0 +1,48 @@ +--- +name: tanstack-query-generator +description: Design of the ejected Redocly `tanstack-query` client generator. Read it, and update it, before changing generators/tanstack-query.mjs. +--- + +# The `tanstack-query` generator — its skill + +This file is the DESIGN of your ejected `tanstack-query` generator (`generators/tanstack-query.mjs`): +**to change the generator, edit this skill first, then make the code match it** — a diff +to `generators/tanstack-query.mjs` that has no covering sentence here is incomplete. + +## What it emits + +Query/mutation option factories for TanStack Query — `Options()`, +`Mutation()`, and `InfiniteOptions()` for paginated operations — plus exported +query keys. One generator, four framework variants (`react` default, `-vue`, +`-svelte`, `-solid`) differing only in the imported package. + +## Design decisions that must hold + +- **Options factories, not hooks:** consumers call `useQuery(Options(...))`, so the + output works with any of the framework adapters and stays testable. +- **`queryKeyPrefix`** namespaces every key when several clients share a cache. +- **Infinite queries** derive `getNextPageParam` from the resolved pagination rule; a + `link`-style rule reads the `Link` header the descriptor declares. +- **`envelope` is excluded and stripped** — cached data is the plain body. +- Requires `sdk`; throw-mode only (it wraps thrown errors into query errors). + +## Emitters that implement it + +`emitters/tanstack-query.ts`, `wrapper-support.ts`, `pagination.ts`. + +## Ejecting it + +`redocly eject-generator tanstack-query` ships this generator BUNDLED with the emitter it +uses — one small `.mjs` you own, importing `@redocly/client-generator` and +`@redocly/openapi-core`. The framework is a single argument in the ejected file's default +export (`tanstackQueryGenerator('react')`), so switch it to `'vue'`, `'svelte'`, or +`'solid'` there instead of ejecting four near-identical copies. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Make `generators/tanstack-query.mjs` match it. +3. Run `redocly generate-client` and inspect the `git diff` of the generated output — + generated files are never hand-edited. + +Newer built-in versions merge in with `redocly eject-generator tanstack-query --update`. diff --git a/packages/client-generator/eject-assets/skills/transformers-generator/SKILL.md b/packages/client-generator/eject-assets/skills/transformers-generator/SKILL.md new file mode 100644 index 0000000000..947911d405 --- /dev/null +++ b/packages/client-generator/eject-assets/skills/transformers-generator/SKILL.md @@ -0,0 +1,43 @@ +--- +name: transformers-generator +description: Design of the ejected Redocly `transformers` client generator. Read it, and update it, before changing generators/transformers.mjs. +--- + +# The `transformers` generator — its skill + +This file is the DESIGN of your ejected `transformers` generator (`generators/transformers.mjs`): +**to change the generator, edit this skill first, then make the code match it** — a diff +to `generators/transformers.mjs` that has no covering sentence here is incomplete. + +## What it emits + +Per-schema `to()` / `from()` converters that turn wire JSON into typed +values and back — the bridge for `dateType: Date` clients. + +## Design decisions that must hold + +- **Requires `dateType: Date`** (declared as `dateTypes: ['Date']`, so a mismatched + selection fails fast): the converters assign `Date` objects to fields the sdk types as + `Date`, which only type-checks in that mode. +- **Imports the sdk's schema TYPES** (so `sdk` is required) and nothing else. +- Converters are pure and total: every named schema gets a pair, nested structures + recurse, and a missing optional stays missing. + +## Emitters that implement it + +`emitters/transformers.ts`. + +## Ejecting it + +`redocly eject-generator transformers` ships this generator BUNDLED with the emitter it +uses — one small `.mjs` you own, importing `@redocly/client-generator` and +`@redocly/openapi-core`. Change which fields are converted, or how, and regenerate. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Make `generators/transformers.mjs` match it. +3. Run `redocly generate-client` and inspect the `git diff` of the generated output — + generated files are never hand-edited. + +Newer built-in versions merge in with `redocly eject-generator transformers --update`. diff --git a/packages/client-generator/eject-assets/skills/zod-generator/SKILL.md b/packages/client-generator/eject-assets/skills/zod-generator/SKILL.md new file mode 100644 index 0000000000..4e6ea0e987 --- /dev/null +++ b/packages/client-generator/eject-assets/skills/zod-generator/SKILL.md @@ -0,0 +1,49 @@ +--- +name: zod-generator +description: Design of the ejected Redocly `zod` client generator. Read it, and update it, before changing generators/zod.mjs. +--- + +# The `zod` generator — its skill + +This file is the DESIGN of your ejected `zod` generator (`generators/zod.mjs`): +**to change the generator, edit this skill first, then make the code match it** — a diff +to `generators/zod.mjs` that has no covering sentence here is incomplete. + +## What it emits + +A standalone `.zod.ts`: one `export const Schema` per named IR schema, the +`operationSchemas` request/response map, and a `zodValidation()` middleware. + +## Design decisions that must hold + +- **The client stays dependency-free.** zod is the CONSUMER's peer dependency; the + generated client never imports this module, and this module never imports the client. +- **Output-mode-agnostic:** one module beside the client whatever the sdk's layout. +- **Emits nothing** when the model has neither named schemas nor JSON operation bodies — + an empty file is worse than no file. +- Validation is opt-in at runtime (`use(zodValidation())`), never automatic. +- **Only ERASABLE TypeScript.** The module must run under `node --experimental-strip-types` + with no build step, so nothing that needs a transform is emitted: no `enum`, no + `namespace`, and no constructor parameter properties. `ZodValidationError` therefore + declares its fields and assigns them in the constructor body — `constructor(readonly +operationId: string)` fails strip-only mode, which is how the generated CLI broke when it + imported this module. + +## Emitters that implement it + +`emitters/zod.ts` (schema expressions + module assembly). + +## Ejecting it + +`redocly eject-generator zod` ships this generator BUNDLED with the emitter it uses — one +small `.mjs` you own, importing `@redocly/client-generator` and `@redocly/openapi-core`. +Change the schema shapes, the naming, or what gets a schema at all, and regenerate. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Make `generators/zod.mjs` match it. +3. Run `redocly generate-client` and inspect the `git diff` of the generated output — + generated files are never hand-edited. + +Newer built-in versions merge in with `redocly eject-generator zod --update`. diff --git a/packages/client-generator/scripts/generate-eject-assets.mjs b/packages/client-generator/scripts/generate-eject-assets.mjs index 28487a9f6c..7c3f9d2ec7 100644 --- a/packages/client-generator/scripts/generate-eject-assets.mjs +++ b/packages/client-generator/scripts/generate-eject-assets.mjs @@ -1,16 +1,26 @@ +import { build } from 'esbuild'; import { spawnSync } from 'node:child_process'; -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import ts from 'typescript'; import { ejectedSkill } from './ejected-skill.mjs'; -// Build the ejectable generator assets: the neutral-toolkit language generators, -// type-stripped to plain ESM (comments preserved) with imports rewritten to the -// public entries, plus a provenance header and the `defineGenerator`-shaped -// default export the resolver loads. `redocly eject-generator ` copies -// these into the user's repo verbatim. +// Build the ejectable generator assets — one `.mjs` per built-in generator, which +// `redocly eject-generator ` copies into the user's repo verbatim. Two shapes, +// because the generators have two shapes: +// +// - A language generator is ONE self-contained file, so it ships as its own source, +// type-stripped with comments preserved and its imports rewritten to the public +// entries. The user reads their own generator, exactly as we wrote it. +// - A TypeScript generator is a thin entry over shared emitters, so it ships BUNDLED +// with the emitters it uses (esbuild, unminified, one module comment per source file). +// `@redocly/client-generator` and `@redocly/openapi-core` stay external — those are +// the two packages an ejected generator imports. +// +// Both get a provenance header and the `defineGenerator`-shaped default export the +// resolver loads. const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..'); const { version } = JSON.parse(readFileSync(join(pkgRoot, 'package.json'), 'utf-8')); const outDir = join(pkgRoot, 'eject-assets', 'generators'); @@ -33,13 +43,106 @@ writeFileSync( ].join('\n') ); -const EJECTABLE = [ +/** The provenance header every ejected file carries; `--update` reads the version from it. */ +function provenanceHeader(name) { + return ( + [ + `// Ejected from @redocly/client-generator@${version} — the built-in "${name}" generator.`, + '// This file is yours: edit freely; the generated client stays machine-owned and is', + '// rebuilt by `redocly generate-client`. Newer generator versions merge in with', + `// \`redocly eject-generator ${name} --update\`.`, + ].join('\n') + '\n' + ); +} + +/** The default export the resolver loads, appended to every asset. */ +function defaultExport(name, run, sample) { + return ( + `\nexport default {\n name: '${name}',\n run: ${run},\n` + + (sample === undefined ? '' : ` sample: ${sample},\n`) + + // The caret range the ejected copy was written against: this version's model and + // helpers, plus every compatible release after it. + ` requiresGenerator: '^${version}',\n};\n` + ); +} + +/** Fail the build loudly — a broken asset would only surface in a user's repo. */ +function checkSyntax(outFile, name) { + const check = spawnSync(process.execPath, ['--check', outFile], { encoding: 'utf-8' }); + if (check.status !== 0) { + process.stderr.write(`eject asset ${name}.mjs failed node --check:\n${check.stderr}`); + process.exit(1); + } +} + +/** The generator's design, rewritten for the user's repo and shipped as an agent skill. */ +function writeSkill(name) { + const skill = readFileSync(join(pkgRoot, 'src', 'generators', name, 'AGENTS.md'), 'utf-8'); + mkdirSync(join(skillsDir, `${name}-generator`), { recursive: true }); + writeFileSync(join(skillsDir, `${name}-generator`, 'SKILL.md'), ejectedSkill(skill, name)); +} + +const LANGUAGE = [ { name: 'python', run: 'pythonGenerator', sample: 'pythonSample' }, { name: 'go', run: 'goGenerator', sample: 'goSample' }, { name: 'php', run: 'phpGenerator', sample: 'phpSample' }, ]; -for (const { name, run, sample } of EJECTABLE) { +/** + * 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: 'sdk', imports: ['sdkGenerator', 'sdkSample'], run: 'sdkGenerator', sample: 'sdkSample' }, + { 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'], run: 'cliGenerator', sample: 'cliSample' }, + { + name: 'tanstack-query', + imports: ['tanstackQueryGenerator'], + run: "tanstackQueryGenerator('react')", + }, +]; + +for (const { name, imports, run, sample } 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) + ); + 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 } of LANGUAGE) { const source = readFileSync(join(pkgRoot, 'src', 'generators', name, 'index.ts'), 'utf-8') .replaceAll("'../../authoring/index.js'", "'@redocly/client-generator'") .replaceAll( @@ -53,27 +156,8 @@ for (const { name, run, sample } of EJECTABLE) { removeComments: false, }, }).outputText; - const header = [ - `// Ejected from @redocly/client-generator@${version} — the built-in "${name}" generator.`, - '// This file is yours: edit freely; the generated client stays machine-owned and is', - '// rebuilt by `redocly generate-client`. Newer generator versions merge in with', - '// `redocly eject-generator ' + name + ' --update`.', - '', - ].join('\n'); - // The caret range the ejected copy was written against: this version's model and - // helpers, plus every compatible release after it. - const footer = `\nexport default {\n name: '${name}',\n run: ${run},\n sample: ${sample},\n requiresGenerator: '^${version}',\n};\n`; const outFile = join(outDir, `${name}.mjs`); - writeFileSync(outFile, header + stripped + footer); - 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 OWN design ships as `.claude/skills/-generator/SKILL.md`, so the - // agent that edits the ejected file starts from the design instead of reverse-engineering - // it. The intro and modify loop are rewritten for the user's repo on the way. - 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(outFile, provenanceHeader(name) + stripped + defaultExport(name, run, sample)); + checkSyntax(outFile, name); + writeSkill(name); } 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 9a54a6f3f0..906816ce01 100644 --- a/packages/client-generator/src/generators/__tests__/generator-skills.test.ts +++ b/packages/client-generator/src/generators/__tests__/generator-skills.test.ts @@ -11,12 +11,13 @@ import { ejectedSkill } from '../../../scripts/ejected-skill.mjs'; // missing its modify-loop anchors, fails here. const generatorsDir = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -/** Generators whose whole implementation is one file, so eject ships them. */ -const EJECTABLE = ['python', 'go', 'php']; -/** TypeScript-emitting generators: thin entries over the shared emitters. */ +/** Language generators: one self-contained file, ejected as its own source. */ +const LANGUAGE = ['python', 'go', 'php']; +/** TypeScript generators: thin entries over shared emitters, ejected bundled with them. */ const TYPESCRIPT = ['sdk', 'zod', 'mock', 'cli', 'swr', 'tanstack-query', 'transformers']; +const EJECTABLE = [...LANGUAGE, ...TYPESCRIPT]; -describe.each([...EJECTABLE, ...TYPESCRIPT])('%s generator skill', (name) => { +describe.each(EJECTABLE)('%s generator skill', (name) => { const skillPath = join(generatorsDir, name, 'AGENTS.md'); it('exists next to the generator', () => { @@ -31,7 +32,7 @@ describe.each([...EJECTABLE, ...TYPESCRIPT])('%s generator skill', (name) => { }); }); -describe.each(EJECTABLE)('%s generator skill ships to users', (name) => { +describe.each(LANGUAGE)('%s generator skill ships to users', (name) => { const skillPath = join(generatorsDir, name, 'AGENTS.md'); it('names its runtime', () => { @@ -54,10 +55,30 @@ describe.each(EJECTABLE)('%s generator skill ships to users', (name) => { }); }); -describe.each(TYPESCRIPT)('%s generator skill (not ejectable)', (name) => { - it('points at the emitters that implement it and at the customization path', () => { +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('Not ejectable'); + expect(skill).toContain('## Ejecting it'); + // The two packages a bundled generator imports — the user installs both. + expect(skill).toContain('@redocly/openapi-core'); + }); +}); + +describe.each(EJECTABLE)('%s ships an eject asset', (name) => { + const assetsDir = join(generatorsDir, '../../eject-assets'); + + it('has a generator asset and a skill beside it', () => { + expect(existsSync(join(assetsDir, 'generators', `${name}.mjs`))).toBe(true); + const skill = readFileSync(join(assetsDir, 'skills', `${name}-generator`, 'SKILL.md'), 'utf-8'); + expect(skill.startsWith(`---\nname: ${name}-generator\n`)).toBe(true); + }); + + it('declares the default export the resolver loads, with a version range', () => { + // The bundled assets go through esbuild, which normalizes quotes — match either. + const asset = readFileSync(join(assetsDir, 'generators', `${name}.mjs`), 'utf-8'); + expect(asset).toMatch(new RegExp(`name: ['"]${name}['"]`)); + expect(asset).toMatch(/requiresGenerator: ['"]\^\d+\.\d+\.\d+['"]/); + expect(asset).toContain('Ejected from @redocly/client-generator@'); }); }); diff --git a/packages/client-generator/src/generators/cli/AGENTS.md b/packages/client-generator/src/generators/cli/AGENTS.md index e611f1040b..46ab9d5baf 100644 --- a/packages/client-generator/src/generators/cli/AGENTS.md +++ b/packages/client-generator/src/generators/cli/AGENTS.md @@ -45,19 +45,12 @@ with `--help`, a `schema ` introspection command, and `--dry-run`. `emitters/cli.ts` (commands + module), plus the sdk's operation types. -## Not ejectable — and the customization path +## Ejecting it -`redocly eject-generator` covers the standalone language SDKs (`python`, `go`, `php`), -whose entire generator is one self-contained file. This generator is a thin entry over -the SHARED TypeScript emitters listed above, so handing you a copy of the entry would -hand you nothing to customize. Customize the OUTPUT instead: - -- `client.setup` bakes publisher defaults into the generated client. -- Middleware and `configure()` change behavior at runtime, not at generate time. -- A custom generator (`defineGenerator`) emits your own artifact beside the client. - -Ask for a helper or a knob you're missing rather than working around it — that request -is the roadmap signal. +`redocly eject-generator cli` ships this generator BUNDLED with the emitters it uses — one +`.mjs` you own, importing `@redocly/client-generator` and `@redocly/openapi-core`. Change +the command surface, the help layout, or the exit-code mapping, and regenerate. The exit +codes are a contract for scripts, so change them only deliberately. ## The modify loop diff --git a/packages/client-generator/src/generators/mock/AGENTS.md b/packages/client-generator/src/generators/mock/AGENTS.md index 583bc9d2c5..7e58e2675a 100644 --- a/packages/client-generator/src/generators/mock/AGENTS.md +++ b/packages/client-generator/src/generators/mock/AGENTS.md @@ -23,19 +23,11 @@ A standalone MSW module: `create()` data factories, `Handler()` / `emitters/mock.ts`, `mock-value.ts` (data trees), `faker.ts`, `sample.ts`. -## Not ejectable — and the customization path +## Ejecting it -`redocly eject-generator` covers the standalone language SDKs (`python`, `go`, `php`), -whose entire generator is one self-contained file. This generator is a thin entry over -the SHARED TypeScript emitters listed above, so handing you a copy of the entry would -hand you nothing to customize. Customize the OUTPUT instead: - -- `client.setup` bakes publisher defaults into the generated client. -- Middleware and `configure()` change behavior at runtime, not at generate time. -- A custom generator (`defineGenerator`) emits your own artifact beside the client. - -Ask for a helper or a knob you're missing rather than working around it — that request -is the roadmap signal. +`redocly eject-generator mock` ships this generator BUNDLED with the emitter it uses — one +small `.mjs` you own, importing `@redocly/client-generator` and `@redocly/openapi-core`. +Change the data strategy, the handler shape, or the factory surface, and regenerate. ## The modify loop diff --git a/packages/client-generator/src/generators/sdk/AGENTS.md b/packages/client-generator/src/generators/sdk/AGENTS.md index e97f632f86..0a5b462743 100644 --- a/packages/client-generator/src/generators/sdk/AGENTS.md +++ b/packages/client-generator/src/generators/sdk/AGENTS.md @@ -37,19 +37,18 @@ and either the embedded runtime (`runtime: inline`) or imports from sugar), `descriptor.ts`, `ts-type.ts`/`ts-literal.ts` (type + data text), `sse.ts`, `pagination.ts`, `response-headers.ts`, `inline-runtime.ts`, `setup-bake.ts`. -## Not ejectable — and the customization path - -`redocly eject-generator` covers the standalone language SDKs (`python`, `go`, `php`), -whose entire generator is one self-contained file. This generator is a thin entry over -the SHARED TypeScript emitters listed above, so handing you a copy of the entry would -hand you nothing to customize. Customize the OUTPUT instead: - -- `client.setup` bakes publisher defaults into the generated client. -- Middleware and `configure()` change behavior at runtime, not at generate time. -- A custom generator (`defineGenerator`) emits your own artifact beside the client. - -Ask for a helper or a knob you're missing rather than working around it — that request -is the roadmap signal. +## Ejecting it + +`redocly eject-generator sdk` ships this generator BUNDLED with the emitters it uses — +one `.mjs` you own, unminified, with a comment marking each source module. It imports +only `@redocly/client-generator` (the toolkit and the embedded runtime) and +`@redocly/openapi-core` (`logger`, `isPlainObject`), so runtime fixes still arrive by +`npm update`. + +It is the largest of them (the whole client emitter plus the runtime it embeds), so reach +for the smaller paths first when they fit: `client.setup` bakes publisher defaults into the +generated client, and middleware or `configure()` change behavior at run time rather than +generation time. ## The modify loop diff --git a/packages/client-generator/src/generators/swr/AGENTS.md b/packages/client-generator/src/generators/swr/AGENTS.md index 77f486e0a7..355366ed16 100644 --- a/packages/client-generator/src/generators/swr/AGENTS.md +++ b/packages/client-generator/src/generators/swr/AGENTS.md @@ -22,19 +22,11 @@ React SWR hooks over the sdk's exported operation functions: `use()` with a `emitters/swr.ts`, `wrapper-support.ts` (shared wrappable-operation policy). -## Not ejectable — and the customization path +## Ejecting it -`redocly eject-generator` covers the standalone language SDKs (`python`, `go`, `php`), -whose entire generator is one self-contained file. This generator is a thin entry over -the SHARED TypeScript emitters listed above, so handing you a copy of the entry would -hand you nothing to customize. Customize the OUTPUT instead: - -- `client.setup` bakes publisher defaults into the generated client. -- Middleware and `configure()` change behavior at runtime, not at generate time. -- A custom generator (`defineGenerator`) emits your own artifact beside the client. - -Ask for a helper or a knob you're missing rather than working around it — that request -is the roadmap signal. +`redocly eject-generator swr` ships this generator BUNDLED with the emitter it uses — one +small `.mjs` you own, importing `@redocly/client-generator` and `@redocly/openapi-core`. +Change the hook shape or the key strategy, and regenerate. ## The modify loop diff --git a/packages/client-generator/src/generators/tanstack-query/AGENTS.md b/packages/client-generator/src/generators/tanstack-query/AGENTS.md index 78fd453893..6455b7913b 100644 --- a/packages/client-generator/src/generators/tanstack-query/AGENTS.md +++ b/packages/client-generator/src/generators/tanstack-query/AGENTS.md @@ -24,19 +24,13 @@ query keys. One generator, four framework variants (`react` default, `-vue`, `emitters/tanstack-query.ts`, `wrapper-support.ts`, `pagination.ts`. -## Not ejectable — and the customization path +## Ejecting it -`redocly eject-generator` covers the standalone language SDKs (`python`, `go`, `php`), -whose entire generator is one self-contained file. This generator is a thin entry over -the SHARED TypeScript emitters listed above, so handing you a copy of the entry would -hand you nothing to customize. Customize the OUTPUT instead: - -- `client.setup` bakes publisher defaults into the generated client. -- Middleware and `configure()` change behavior at runtime, not at generate time. -- A custom generator (`defineGenerator`) emits your own artifact beside the client. - -Ask for a helper or a knob you're missing rather than working around it — that request -is the roadmap signal. +`redocly eject-generator tanstack-query` ships this generator BUNDLED with the emitter it +uses — one small `.mjs` you own, importing `@redocly/client-generator` and +`@redocly/openapi-core`. The framework is a single argument in the ejected file's default +export (`tanstackQueryGenerator('react')`), so switch it to `'vue'`, `'svelte'`, or +`'solid'` there instead of ejecting four near-identical copies. ## The modify loop diff --git a/packages/client-generator/src/generators/transformers/AGENTS.md b/packages/client-generator/src/generators/transformers/AGENTS.md index 7a650a5c73..ba0a643890 100644 --- a/packages/client-generator/src/generators/transformers/AGENTS.md +++ b/packages/client-generator/src/generators/transformers/AGENTS.md @@ -21,19 +21,11 @@ values and back — the bridge for `dateType: Date` clients. `emitters/transformers.ts`. -## Not ejectable — and the customization path +## Ejecting it -`redocly eject-generator` covers the standalone language SDKs (`python`, `go`, `php`), -whose entire generator is one self-contained file. This generator is a thin entry over -the SHARED TypeScript emitters listed above, so handing you a copy of the entry would -hand you nothing to customize. Customize the OUTPUT instead: - -- `client.setup` bakes publisher defaults into the generated client. -- Middleware and `configure()` change behavior at runtime, not at generate time. -- A custom generator (`defineGenerator`) emits your own artifact beside the client. - -Ask for a helper or a knob you're missing rather than working around it — that request -is the roadmap signal. +`redocly eject-generator transformers` ships this generator BUNDLED with the emitter it +uses — one small `.mjs` you own, importing `@redocly/client-generator` and +`@redocly/openapi-core`. Change which fields are converted, or how, and regenerate. ## The modify loop diff --git a/packages/client-generator/src/generators/zod/AGENTS.md b/packages/client-generator/src/generators/zod/AGENTS.md index e5f6335d19..3e932b5b5e 100644 --- a/packages/client-generator/src/generators/zod/AGENTS.md +++ b/packages/client-generator/src/generators/zod/AGENTS.md @@ -27,19 +27,11 @@ operationId: string)` fails strip-only mode, which is how the generated CLI brok `emitters/zod.ts` (schema expressions + module assembly). -## Not ejectable — and the customization path +## Ejecting it -`redocly eject-generator` covers the standalone language SDKs (`python`, `go`, `php`), -whose entire generator is one self-contained file. This generator is a thin entry over -the SHARED TypeScript emitters listed above, so handing you a copy of the entry would -hand you nothing to customize. Customize the OUTPUT instead: - -- `client.setup` bakes publisher defaults into the generated client. -- Middleware and `configure()` change behavior at runtime, not at generate time. -- A custom generator (`defineGenerator`) emits your own artifact beside the client. - -Ask for a helper or a knob you're missing rather than working around it — that request -is the roadmap signal. +`redocly eject-generator zod` ships this generator BUNDLED with the emitter it uses — one +small `.mjs` you own, importing `@redocly/client-generator` and `@redocly/openapi-core`. +Change the schema shapes, the naming, or what gets a schema at all, and regenerate. ## The modify loop diff --git a/tests/e2e/generate-client/eject.test.ts b/tests/e2e/generate-client/eject.test.ts index 832cef927b..6cac0f162c 100644 --- a/tests/e2e/generate-client/eject.test.ts +++ b/tests/e2e/generate-client/eject.test.ts @@ -139,11 +139,42 @@ describe('eject-generator (end-to-end)', () => { ); }, 60_000); - it('sdk prints guidance instead of ejecting; unknown names error', () => { - const sdk = run(project, ['eject-generator', 'sdk']); - expect(sdk.status).toBe(0); - expect(sdk.stderr + sdk.stdout).toContain('not ejectable'); - expect(existsSync(join(project, 'generators/sdk.mjs'))).toBe(false); + it('THE headline holds for a bundled TypeScript generator too', () => { + const eject = run(project, ['eject-generator', 'zod']); + expect(eject.status, eject.stderr).toBe(0); + + const builtin = run(project, [ + 'generate-client', + 'openapi.yaml', + '--output', + 'zod-builtin/client.ts', + '--generator', + 'sdk', + '--generator', + 'zod', + ]); + expect(builtin.status, builtin.stderr).toBe(0); + const ejected = run(project, [ + 'generate-client', + 'openapi.yaml', + '--output', + 'zod-ejected/client.ts', + '--generator', + 'sdk', + '--generator', + './generators/zod.mjs', + ]); + expect(ejected.status, ejected.stderr).toBe(0); + expect(readFileSync(join(project, 'zod-ejected/client.zod.ts'), 'utf-8')).toBe( + readFileSync(join(project, 'zod-builtin/client.zod.ts'), 'utf-8') + ); + }, 60_000); + + it('a framework variant points at the generator it is an argument of; unknown names error', () => { + const variant = run(project, ['eject-generator', 'tanstack-query-vue']); + expect(variant.status).toBe(0); + expect(variant.stderr + variant.stdout).toContain("tanstackQueryGenerator('vue')"); + expect(existsSync(join(project, 'generators/tanstack-query-vue.mjs'))).toBe(false); expect(run(project, ['eject-generator', 'nowhere']).status).not.toBe(0); }, 60_000); From 1acf29d6e1026f7a0ef25edc11b9626fbe2badcd Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Thu, 6 Aug 2026 09:26:31 +0300 Subject: [PATCH 108/211] refactor!: drop the AST toolkit so text printing is the only authoring path --- .changeset/agent-friendly-generators.md | 2 + .../@v2/guides/customize-client-generation.md | 3 +- packages/client-generator/CONTEXT.md | 6 +- packages/client-generator/README.md | 45 ++--- .../client-generator/eject-assets/AGENTS.md | 6 +- .../skills/client-generators/SKILL.md | 6 +- .../__tests__/client-assembly.test.ts | 3 +- .../emitters/__tests__/inline-runtime.test.ts | 3 +- .../emitters/__tests__/reserved-names.test.ts | 3 +- .../src/emitters/__tests__/ts-guard.test.ts | 6 +- .../src/emitters/__tests__/ts.test.ts | 156 --------------- .../src/emitters/setup-bake.ts | 15 +- packages/client-generator/src/emitters/ts.ts | 189 ------------------ packages/client-generator/src/plugin.ts | 10 +- tests/e2e/generate-client/examples/README.md | 50 ++--- .../.gitignore | 0 .../README.md | 10 +- .../package.json | 2 +- .../redocly.yaml | 2 +- .../response-map-generator.mjs | 0 .../src/main.ts | 0 .../tsconfig.json | 0 22 files changed, 92 insertions(+), 425 deletions(-) delete mode 100644 packages/client-generator/src/emitters/__tests__/ts.test.ts delete mode 100644 packages/client-generator/src/emitters/ts.ts rename tests/e2e/generate-client/examples/{ast-toolkit-generator => typescript-types-generator}/.gitignore (100%) rename tests/e2e/generate-client/examples/{ast-toolkit-generator => typescript-types-generator}/README.md (80%) rename tests/e2e/generate-client/examples/{ast-toolkit-generator => typescript-types-generator}/package.json (81%) rename tests/e2e/generate-client/examples/{ast-toolkit-generator => typescript-types-generator}/redocly.yaml (92%) rename tests/e2e/generate-client/examples/{ast-toolkit-generator => typescript-types-generator}/response-map-generator.mjs (100%) rename tests/e2e/generate-client/examples/{ast-toolkit-generator => typescript-types-generator}/src/main.ts (100%) rename tests/e2e/generate-client/examples/{ast-toolkit-generator => typescript-types-generator}/tsconfig.json (100%) diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index 8f92d21d8e..66b6ced09f 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -20,3 +20,5 @@ Generator compatibility is the package version under semver instead of a separat `eject-generator --update` no longer needs a committed `.pristine/` snapshot: the merge base is the version recorded in the ejected file's own header, fetched from the registry when it differs from the installed one. An existing `.pristine/` copy is still used as the base and can then be deleted. Every built-in generator is now ejectable, not just the language SDKs: a TypeScript generator (`sdk`, `zod`, `mock`, `cli`, `swr`, `tanstack-query`, `transformers`) ships bundled with the emitters it uses — one unminified `.mjs` you own that produces byte-identical output until you change it. The `tanstack-query-vue`/`-svelte`/`-solid` variants point at `tanstack-query`, whose framework is one argument in the ejected file. + +There is one way to author a generator: printing text with the language-neutral toolkit. The AST-era exports (`ts`, `printStatements`, `parseStatements`, `schemaToTypeNode`) are gone; TypeScript generators use the source-text renderers (`tsType`, `tsJsdoc`, `codeLiteral`, `operationSignature`). `typescript` stays an optional peer dependency needed only to bake a `--setup` module. diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index e380843746..6a7c0f62b4 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -143,6 +143,7 @@ The package root exports pure helpers over the API model that cover the cross-la These helpers plus `Printer` are the ONE way to author a generator, in any output language. Nothing in the authoring path depends on the `typescript` package, so a generator also runs in the browser or any other embedded host. +The only part of `generate-client` that parses TypeScript is baking a `--setup` module, which is why `typescript` is an optional peer dependency: install it if you use that flag, and skip it otherwise. `redocly eject-generator ` writes this guidance into your repo as an agent skill, so your coding agent has the contract, the model reference, and this helper table without being told. @@ -212,7 +213,7 @@ With `codeSamples: true` in the `client` block, generation collects every select The built-in `sdk` generator ships the TypeScript reference implementation, so enabling the flag alone gives your Redoc docs per-operation TypeScript examples that never drift from the SDK. Import-specifier generators execute at generation time — they carry the same trust level as any installed dependency you run. -See the [`ast-toolkit-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/ast-toolkit-generator) for the runnable `tsType`-based plugin (including type-importing referenced schemas), the [`custom-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/custom-generator) for a minimal string-building one, and the [`nested-facade` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/nested-facade) for a realistic one that derives an `api..` facade from the description's tags. +See the [`typescript-types-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/typescript-types-generator) for the runnable `tsType`-based plugin (including type-importing referenced schemas), the [`custom-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/custom-generator) for a minimal string-building one, and the [`nested-facade` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/nested-facade) for a realistic one that derives an `api..` facade from the description's tags. ## Resources diff --git a/packages/client-generator/CONTEXT.md b/packages/client-generator/CONTEXT.md index 3925fc3e47..2d8c3c4641 100644 --- a/packages/client-generator/CONTEXT.md +++ b/packages/client-generator/CONTEXT.md @@ -39,10 +39,10 @@ _Avoid_: streamSchema, eventSchema (in code identifiers — `itemSchema` mirrors ### Emission **Emitter**: -Builds a TypeScript **AST** (`ts.factory` nodes) from the IR. +Renders TypeScript source TEXT from the IR, through `Printer`. Lives in `emitters/`. -Each emitter is deep — one narrow entry point over hidden node-building bulk — and owns a single concern: `types.ts` (`typesStatements`/`schemaToTypeNode`), `type-guards.ts` (`typeGuardStatements`), `descriptor.ts` (the `OPERATIONS` descriptor map + the `Ops` type), `operation-aliases.ts`/`operation-types.ts` (the `*` aliases and their type builders), `sse.ts` (the **SSE** detection seam: `isSseOp`/`partitionOps`/`sseEventType`/`sseDataKind`), and `inline-runtime.ts` (the **inline assembler**). -The foundation module `ts.ts` owns the shared printer and ergonomics: `printNodes` (nodes → source), `parseStatements` (parse hand-authored source into nodes), and `jsdoc` (attach a block comment). +Each emitter is deep — one narrow entry point over hidden rendering bulk — and owns a single concern: `types.ts`, `type-guards.ts`, `descriptor.ts` (the `OPERATIONS` descriptor map + the `Ops` type), `operation-aliases.ts`/`operation-types.ts` (the `*` aliases), `ts-type.ts` (`tsType`, the schema→type renderer), `sse.ts` (the **SSE** detection seam: `isSseOp`/`partitionOps`/`sseEventType`/`sseDataKind`), and `inline-runtime.ts` (the **inline assembler**). +`setup-bake.ts` is the only module that parses TypeScript (a publisher `--setup` module), which is why `typescript` is an optional peer dependency loaded lazily. `package-client.ts` is the shared _wiring_ emitter: it assembles each file's content — identical for both runtimes except the runtime block (import vs embed) — and prints **once**, exposing `emitClientSingleFile` / `emitClientSplit`. Low-level text helpers (`pascalCase`, `splitLines`, `joinSections`) stay private in `support.ts`, and the JSDoc-body builder in `jsdoc.ts` — consumed only by the deep emitters, never by writers. _Avoid_: renderer, codegen. diff --git a/packages/client-generator/README.md b/packages/client-generator/README.md index 7b10018271..937ef3f76b 100644 --- a/packages/client-generator/README.md +++ b/packages/client-generator/README.md @@ -55,43 +55,32 @@ With `runtime: 'package'` the generated client also imports its whole engine fro ### Write a custom generator A custom generator reads the same API model the built-ins consume, runs in the same pass, and returns files. -Build real TypeScript with the emit toolkit from `@redocly/client-generator/generate` — the same `ts.factory` + printer the built-in generators use, so the schema→type mapping matches the sdk's exactly: +Emitters print text: `Printer` handles indentation, and `tsType` is the same schema→type renderer the built-in sdk uses, so the mapping (refs, arrays, unions, formats, parenthesization) matches the generated client exactly: ```ts // response-map-generator.ts -import { defineGenerator } from '@redocly/client-generator'; -import { printStatements, schemaToTypeNode, ts } from '@redocly/client-generator/generate'; - -const { factory } = ts; +import { defineGenerator, Printer } from '@redocly/client-generator'; +import { tsType } from '@redocly/client-generator/generate'; export default defineGenerator({ name: 'response-map', requires: ['sdk'], run({ model, outputPath }) { + const printer = new Printer(); // One `ResponseShapes` entry per operation with a JSON success body. - const members = model.services - .flatMap((service) => service.operations) - .flatMap((op) => { - const success = op.successResponses.find((r) => r.contentType.includes('json')); - if (!success) return []; - return [ - factory.createPropertySignature( - undefined, - op.name, - undefined, - schemaToTypeNode(success.schema) - ), - ]; - }); - const alias = factory.createTypeAliasDeclaration( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - 'ResponseShapes', - undefined, - factory.createTypeLiteralNode(members) + printer.block( + 'export type ResponseShapes = {', + () => { + for (const service of model.services) { + for (const op of service.operations) { + const success = op.successResponses.find((r) => r.contentType.includes('json')); + if (success) printer.line(`${op.name}: ${tsType(success.schema)};`); + } + } + }, + '};' ); - return [ - { path: outputPath.replace(/\.ts$/, '.responses.ts'), content: printStatements([alias]) }, - ]; + return [{ path: outputPath.replace(/\.ts$/, '.responses.ts'), content: printer.toString() }]; }, }); ``` @@ -150,7 +139,7 @@ Authors a custom generator (`{ name, run }` plus optional `requires`/`errorModes function defineGenerator(generator: CustomGenerator): CustomGenerator; ``` -The `@redocly/client-generator/generate` entry also exports the emit toolkit the built-ins use (`ts`, `printStatements`, `parseStatements`, `operationSignature`, `schemaToTypeNode`, `pascalCase`, …), and the package root exports the IR types, so a custom generator emits TypeScript exactly as the first-party ones do — see the [`ast-toolkit-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/ast-toolkit-generator). +The `@redocly/client-generator/generate` entry also exports the TypeScript renderers the built-ins use (`tsType`, `tsJsdoc`, `codeLiteral`, `operationSignature`, `pascalCase`, `safeIdent`), and the package root exports the IR types plus the language-neutral toolkit, so a custom generator emits TypeScript exactly as the first-party ones do — see the [`typescript-types-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/typescript-types-generator). ### `defineClientSetup` diff --git a/packages/client-generator/eject-assets/AGENTS.md b/packages/client-generator/eject-assets/AGENTS.md index c6ecc401ec..4eef87fd28 100644 --- a/packages/client-generator/eject-assets/AGENTS.md +++ b/packages/client-generator/eject-assets/AGENTS.md @@ -106,8 +106,10 @@ authored with exactly this toolkit and nothing else — models via `flattenAllOf `enumValues`/`discriminatorCases`, all code through `Printer`, every name through `identifierFor(..., RESERVED_WORDS.python)`. -TypeScript-emitting generators may additionally use the TS toolkit from -`@redocly/client-generator/generate` (`ts`, `printStatements`, `schemaToTypeNode`, …). +A generator that emits TypeScript may additionally use the source-text renderers from +`@redocly/client-generator/generate` — `tsType` (schema → type), `tsJsdoc`, `codeLiteral`, +`operationSignature`, `pascalCase`, `safeIdent`. There is no AST toolkit and no +`typescript` dependency: every generator prints text through `Printer`. ## The loop 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 1885959c57..aa5372fecd 100644 --- a/packages/client-generator/eject-assets/skills/client-generators/SKILL.md +++ b/packages/client-generator/eject-assets/skills/client-generators/SKILL.md @@ -111,8 +111,10 @@ authored with exactly this toolkit and nothing else — models via `flattenAllOf `enumValues`/`discriminatorCases`, all code through `Printer`, every name through `identifierFor(..., RESERVED_WORDS.python)`. -TypeScript-emitting generators may additionally use the TS toolkit from -`@redocly/client-generator/generate` (`ts`, `printStatements`, `schemaToTypeNode`, …). +A generator that emits TypeScript may additionally use the source-text renderers from +`@redocly/client-generator/generate` — `tsType` (schema → type), `tsJsdoc`, `codeLiteral`, +`operationSignature`, `pascalCase`, `safeIdent`. There is no AST toolkit and no +`typescript` dependency: every generator prints text through `Printer`. ## The loop 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 bc1b35b481..16b4238172 100644 --- a/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts +++ b/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts @@ -1,7 +1,8 @@ +import ts from 'typescript'; + import type { ApiModel } from '../../intermediate-representation/model.js'; import { emitClientSingleFile } from '../client-assembly.js'; import type { EmitOptions } from '../emit-options.js'; -import { ts } from '../ts.js'; import { modelWith, namedSchema, operation, param, response, SCALAR } from './fixtures.js'; /** The package arm of the shared emitter. */ diff --git a/packages/client-generator/src/emitters/__tests__/inline-runtime.test.ts b/packages/client-generator/src/emitters/__tests__/inline-runtime.test.ts index 44453acc7e..8de8539275 100644 --- a/packages/client-generator/src/emitters/__tests__/inline-runtime.test.ts +++ b/packages/client-generator/src/emitters/__tests__/inline-runtime.test.ts @@ -1,5 +1,6 @@ +import ts from 'typescript'; + import { assembleInlineRuntime } from '../inline-runtime.js'; -import { ts } from '../ts.js'; const NONE = { multipart: false, auth: false, sse: false, setup: false, paginate: false }; const ALL = { multipart: true, auth: true, sse: true, setup: true, paginate: true }; diff --git a/packages/client-generator/src/emitters/__tests__/reserved-names.test.ts b/packages/client-generator/src/emitters/__tests__/reserved-names.test.ts index 5f6ec6ffb3..6311c7987a 100644 --- a/packages/client-generator/src/emitters/__tests__/reserved-names.test.ts +++ b/packages/client-generator/src/emitters/__tests__/reserved-names.test.ts @@ -1,6 +1,7 @@ +import ts from 'typescript'; + import { reservedModuleNames } from '../reserved-names.js'; import { RUNTIME_SOURCES } from '../runtime-sources.js'; -import { ts } from '../ts.js'; /** * Every free identifier of a source — referenced but bound in no enclosing scope, so diff --git a/packages/client-generator/src/emitters/__tests__/ts-guard.test.ts b/packages/client-generator/src/emitters/__tests__/ts-guard.test.ts index 1a439582f4..42b03e5716 100644 --- a/packages/client-generator/src/emitters/__tests__/ts-guard.test.ts +++ b/packages/client-generator/src/emitters/__tests__/ts-guard.test.ts @@ -3,11 +3,11 @@ import { vi } from 'vitest'; describe('typescript compiler API guard', () => { it('fails with instructions when the installed typescript lacks the compiler API (TS 7+)', async () => { // typescript@7 (the native compiler) ships only the tsc binary: `import ts` resolves, - // but every compiler-API member is undefined. Without the guard the module dies on - // its first `ts.*` call with a bare TypeError. + // but every compiler-API member is undefined. Without the guard setup baking — the only + // place we parse TypeScript — dies on its first `ts.*` call with a bare TypeError. vi.resetModules(); vi.doMock('typescript', () => ({ default: { version: '7.0.2' } })); - await expect(import('../ts.js')).rejects.toThrow( + await expect(import('../setup-bake.js')).rejects.toThrow( /TypeScript 7.*ships only.*tsc.*typescript@6/s ); vi.doUnmock('typescript'); diff --git a/packages/client-generator/src/emitters/__tests__/ts.test.ts b/packages/client-generator/src/emitters/__tests__/ts.test.ts deleted file mode 100644 index 1d7ae1c86d..0000000000 --- a/packages/client-generator/src/emitters/__tests__/ts.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { - jsdoc, - literalExpression, - parseExpression, - parseStatements, - printNodes, - printStatements, - ts, -} from '../ts.js'; - -describe('emitters/ts foundation', () => { - describe('printNodes', () => { - it('round-trips an interface declaration', () => { - const decl = ts.factory.createInterfaceDeclaration( - [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], - 'Foo', - undefined, - undefined, - [ - ts.factory.createPropertySignature( - undefined, - 'id', - undefined, - ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword) - ), - ] - ); - expect(printNodes([decl])).toBe('export interface Foo {\n id: string;\n}'); - }); - - it('round-trips a const declaration', () => { - const decl = ts.factory.createVariableStatement( - [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)], - ts.factory.createVariableDeclarationList( - [ - ts.factory.createVariableDeclaration( - 'x', - undefined, - undefined, - ts.factory.createNumericLiteral(1) - ), - ], - ts.NodeFlags.Const - ) - ); - expect(printNodes([decl])).toBe('export const x = 1;'); - }); - - it('joins multiple nodes with a newline', () => { - const lit = (n: number): ts.ExpressionStatement => - ts.factory.createExpressionStatement(ts.factory.createNumericLiteral(n)); - expect(printNodes([lit(1), lit(2)])).toBe('1;\n2;'); - }); - }); - - describe('printStatements', () => { - const lit = (n: number): ts.ExpressionStatement => - ts.factory.createExpressionStatement(ts.factory.createNumericLiteral(n)); - - it('separates top-level declarations with a blank line', () => { - expect(printStatements([lit(1), lit(2)])).toBe('1;\n\n2;'); - }); - - it('prints a single node with no trailing blank line', () => { - expect(printStatements([lit(1)])).toBe('1;'); - }); - }); - - describe('parseStatements', () => { - it('yields re-printable statements from source', () => { - const statements = parseStatements('export const x = 1;'); - expect(statements).toHaveLength(1); - expect(printNodes(statements)).toBe('export const x = 1;'); - }); - }); - - describe('parseExpression', () => { - it('parses a source expression into a re-printable ts.Expression', () => { - const expr = parseExpression('new Blob([])'); - expect(ts.isNewExpression(expr)).toBe(true); - expect(printNodes([expr])).toBe('new Blob([])'); - }); - }); - - describe('literalExpression', () => { - function print(value: unknown): string { - return printStatements([literalExpression(value)]); - } - - it('converts scalars, null, arrays, and objects to expression source', () => { - expect(print('x')).toBe('"x"'); - expect(print(42)).toBe('42'); - expect(print(true)).toBe('true'); - expect(print(false)).toBe('false'); - expect(print(null)).toBe('null'); - expect(print([1, 'a'])).toBe('[1, "a"]'); - expect(print({ a: 1, b: [true] })).toBe('{ a: 1, b: [true] }'); - }); - - it('prints a negative number as a unary minus', () => { - expect(print(-42)).toBe('-42'); - expect(print({ min: -1.5 })).toBe('{ min: -1.5 }'); - }); - - it('quotes object keys that are not identifier-safe', () => { - expect(print({ 'a-b': 1, ok: 2 })).toBe('{ "a-b": 1, ok: 2 }'); - }); - }); - - describe('jsdoc', () => { - it('prints a single-line block comment above the node', () => { - const decl = ts.factory.createVariableStatement( - undefined, - ts.factory.createVariableDeclarationList( - [ - ts.factory.createVariableDeclaration( - 'y', - undefined, - undefined, - ts.factory.createNull() - ), - ], - ts.NodeFlags.Const - ) - ); - const out = printNodes([jsdoc(decl, 'A note.')]); - expect(out).toBe('/**\n * A note.\n */\nconst y = null;'); - }); - - it('prints a multi-line block comment with one star per line', () => { - const decl = ts.factory.createTypeAliasDeclaration( - undefined, - 'T', - undefined, - ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword) - ); - const out = printNodes([jsdoc(decl, 'line one\nline two')]); - expect(out).toBe('/**\n * line one\n * line two\n */\ntype T = string;'); - }); - - it('escapes an embedded `*/` so a hostile description cannot break out of the comment', () => { - const decl = ts.factory.createTypeAliasDeclaration( - undefined, - 'T', - undefined, - ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword) - ); - const out = printNodes([jsdoc(decl, 'evil */ ;globalThis.PWNED=1; /*')]); - // The `*/` is neutralized to `*\/`; no live `*/` survives inside the comment body. - expect(out).toContain('evil *\\/ ;globalThis.PWNED=1; /*'); - expect(out).not.toContain('evil */'); - // Exactly one comment-closing `*/` (the real one the printer appends). - expect(out.match(/\*\//g)).toHaveLength(1); - }); - }); -}); diff --git a/packages/client-generator/src/emitters/setup-bake.ts b/packages/client-generator/src/emitters/setup-bake.ts index 4eb0d67f1f..a3379a3206 100644 --- a/packages/client-generator/src/emitters/setup-bake.ts +++ b/packages/client-generator/src/emitters/setup-bake.ts @@ -1,5 +1,18 @@ +import ts from 'typescript'; + import { NotSupportedError } from '../errors.js'; -import { ts } from './ts.js'; + +// TypeScript 7 (the native compiler) ships only the tsc binary — none of the compiler API +// this module is built on — yet its package resolves fine, so the first `ts.*` call would +// die with a bare TypeError. Fail with instructions instead. Baking a `--setup` module is +// the ONLY place we parse TypeScript, which is why the dependency is an optional peer. +if (typeof ts?.createSourceFile !== 'function') { + throw new Error( + `Baking a --setup module needs the TypeScript compiler API, but the installed \`typescript\` package` + + `${ts?.version ? ` (${ts.version})` : ''} does not include it — TypeScript 7 ships only the native tsc. ` + + `Install TypeScript 6 for generation (npm i -D typescript@6); your app can still compile the generated client with TypeScript 7.` + ); +} const SETUP_IMPORT = '@redocly/client-generator'; diff --git a/packages/client-generator/src/emitters/ts.ts b/packages/client-generator/src/emitters/ts.ts deleted file mode 100644 index eff7ea8c2d..0000000000 --- a/packages/client-generator/src/emitters/ts.ts +++ /dev/null @@ -1,189 +0,0 @@ -// Foundation for AST-based code emission: a shared TypeScript printer plus -// `ts.factory` ergonomics. Emitters build `ts.Node`s and print them through -// `printNodes`; hand-authored reference TypeScript is embedded via -// `parseStatements`. The compiler (`ts`) is re-exported so emitters import the -// factory from one place. - -import ts from 'typescript'; - -import { isIdentifier } from './identifier.js'; -import { escapeJsDoc } from './jsdoc.js'; - -// TypeScript 7 (the native compiler) ships only the tsc binary — none of the compiler -// API everything below is built on — yet its package resolves fine, so the first -// `ts.*` call would die with a bare TypeError. Fail with instructions instead. -if (typeof ts?.createSourceFile !== 'function') { - throw new Error( - `Client generation needs the TypeScript compiler API, but the installed \`typescript\` package` + - `${ts?.version ? ` (${ts.version})` : ''} does not include it — TypeScript 7 ships only the native tsc. ` + - `Install TypeScript 6 for generation (npm i -D typescript@6); your app can still compile the generated client with TypeScript 7.` - ); -} - -export { ts }; - -const printer = ts.createPrinter({ - newLine: ts.NewLineKind.LineFeed, - removeComments: false, -}); - -const blankFile = ts.createSourceFile('', '', ts.ScriptTarget.Latest, false, ts.ScriptKind.TS); - -/** Print a list of nodes to source, tight (one per line) — for import/export groups and single nodes. */ -export function printNodes(nodes: readonly ts.Node[]): string { - return nodes.map(printOne).join('\n'); -} - -/** Print top-level declarations separated by one blank line, for readable declaration bodies. */ -export function printStatements(nodes: readonly ts.Node[]): string { - return nodes.map(printOne).join('\n\n'); -} - -function printOne(node: ts.Node): string { - return printer.printNode(ts.EmitHint.Unspecified, node, sourceFileOf(node)); -} - -// Synthesized (`ts.factory`) nodes have no parent chain — print them against the -// shared blank file. Parsed nodes (from `parseStatements`, built with parent -// nodes set) must print against their own source so literal token text survives. -function sourceFileOf(node: ts.Node): ts.SourceFile { - let current: ts.Node | undefined = node; - while (current) { - if (ts.isSourceFile(current)) return current; - current = current.parent; - } - return blankFile; -} - -/** Parse a source string into its top-level statements (for embedding hand-authored code). */ -export function parseStatements(source: string): ts.Statement[] { - return [ - ...ts.createSourceFile('__embed.ts', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) - .statements, - ]; -} - -/** Parse a single source expression into a ts.Expression (for emitting generator-authored - * expressions like `new Blob([])` that aren't plain data literals). */ -export function parseExpression(source: string): ts.Expression { - const stmt = ts.createSourceFile( - '__expr.ts', - `(${source});`, - ts.ScriptTarget.Latest, - true, - ts.ScriptKind.TS - ).statements[0]; - const parenthesized = (stmt as ts.ExpressionStatement).expression as ts.ParenthesizedExpression; - return parenthesized.expression; -} - -/** - * Attach a block JSDoc leading comment to `node` so it prints as a `/** … *​/` - * block above the node. Multi-line `text` becomes `*`-prefixed lines. - */ -export function jsdoc(node: T, text: string): T { - // Neutralize any embedded `*/` here, at the single choke point every JSDoc block - // flows through: a spec-supplied description/summary/title containing `*/` would - // otherwise close the comment early and turn the rest into live code (injection). - const body = `*\n${escapeJsDoc(text) - .split('\n') - .map((line) => ` * ${line}`.replace(/ +$/, '')) - .join('\n')}\n `; - return ts.addSyntheticLeadingComment(node, ts.SyntaxKind.MultiLineCommentTrivia, body, true); -} - -export { escapeJsDoc } from './jsdoc.js'; - -const { factory } = ts; - -/** - * Shared `ts.factory` builders for the handful of node shapes every emitter was - * re-implementing locally (variable statements, arrow functions, `as const` - * arrays). Centralizing them keeps emitters terse and their output identical. - */ - -/** `export const = ;` */ -export function exportConstStatement(name: string, init: ts.Expression): ts.Statement { - return factory.createVariableStatement( - [factory.createModifier(ts.SyntaxKind.ExportKeyword)], - factory.createVariableDeclarationList( - [factory.createVariableDeclaration(name, undefined, undefined, init)], - ts.NodeFlags.Const - ) - ); -} - -/** An arrow function `() => ` (no explicit return type). */ -export function arrow(params: ts.ParameterDeclaration[], body: ts.ConciseBody): ts.ArrowFunction { - return factory.createArrowFunction( - undefined, - undefined, - params, - undefined, - factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - body - ); -} - -/** An arrow with type parameters, an explicit return type, and a body. */ -export function typedArrow( - typeParameters: ts.TypeParameterDeclaration[], - params: ts.ParameterDeclaration[], - returnType: ts.TypeNode, - body: ts.ConciseBody -): ts.ArrowFunction { - return factory.createArrowFunction( - undefined, - typeParameters, - params, - returnType, - factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), - body - ); -} - -/** `[] as const`. */ -export function constArray(elements: ts.Expression[]): ts.Expression { - return factory.createAsExpression( - factory.createArrayLiteralExpression(elements, false), - factory.createTypeReferenceNode('const') - ); -} - -/** - * A plain JS value as a printable literal expression. Negative numbers print as - * a unary minus over the positive literal (a `NumericLiteral` node cannot carry - * the sign); arrays and objects recurse and print compact, with keys quoted only - * when they fail the identifier GRAMMAR — reserved words (a descriptor's `in` - * field) are legal bare object-literal keys. The primitive overload's narrower - * return type fits `factory.createLiteralTypeNode`. - */ -export function literalExpression( - value: string | number | boolean | null -): ts.LiteralExpression | ts.BooleanLiteral | ts.NullLiteral | ts.PrefixUnaryExpression; -export function literalExpression(value: unknown): ts.Expression; -export function literalExpression(value: unknown): ts.Expression { - if (typeof value === 'string') return factory.createStringLiteral(value); - if (typeof value === 'boolean') return value ? factory.createTrue() : factory.createFalse(); - if (typeof value === 'number') { - return value < 0 - ? factory.createPrefixUnaryExpression( - ts.SyntaxKind.MinusToken, - factory.createNumericLiteral(-value) - ) - : factory.createNumericLiteral(value); - } - if (value === null) return factory.createNull(); - if (Array.isArray(value)) { - return factory.createArrayLiteralExpression(value.map(literalExpression), false); - } - return factory.createObjectLiteralExpression( - Object.entries(value as Record).map(([key, entryValue]) => - factory.createPropertyAssignment( - isIdentifier(key) ? key : factory.createStringLiteral(key), - literalExpression(entryValue) - ) - ), - false - ); -} diff --git a/packages/client-generator/src/plugin.ts b/packages/client-generator/src/plugin.ts index 803d28fe5e..7641e33878 100644 --- a/packages/client-generator/src/plugin.ts +++ b/packages/client-generator/src/plugin.ts @@ -13,8 +13,8 @@ // // // my-generator.ts // import { defineGenerator } from '@redocly/client-generator'; -// // AST toolkit, when string-building isn't enough: -// // import { ts, printStatements } from '@redocly/client-generator/generate'; +// // TypeScript renderers, when a real type is needed rather than guessed text: +// // import { tsType } from '@redocly/client-generator/generate'; // export default defineGenerator({ // name: 'route-map', // requires: ['sdk'], @@ -67,6 +67,6 @@ export type { ServiceModel, } from './intermediate-representation/model.js'; -// The TypeScript-emitting toolkit (`ts`, `printStatements`, `operationSignature`, …) is -// exported from `@redocly/client-generator/generate` — it loads `typescript`, which the -// runtime-only package root must not reach statically. +// The TypeScript-emitting renderers (`tsType`, `operationSignature`, …) are exported from +// `@redocly/client-generator/generate`, which also carries the generation entry point — +// the runtime-only package root stays free of it. diff --git a/tests/e2e/generate-client/examples/README.md b/tests/e2e/generate-client/examples/README.md index 86093d66d6..bc48d74a85 100644 --- a/tests/e2e/generate-client/examples/README.md +++ b/tests/e2e/generate-client/examples/README.md @@ -6,31 +6,31 @@ Most share the cafe spec in [`_shared/cafe.yaml`](./_shared/cafe.yaml); the rest The generated client under `src/api/` is gitignored — CI regenerates every client and type-checks the consumer code against it (the `examples` job), and `zero-install-quickstart` keeps its client committed as the canonical browsable copy, drift-checked in `tests/e2e/generate-client/examples.test.ts`. -| Example | How it's generated | Shows | -| ------------------------------------------------------ | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | -| [fetch-functions](./fetch-functions) | CLI · `sdk`, functions | free functions + `ApiError` | -| [baked-setup](./baked-setup) | CLI · `sdk`, functions | publisher defaults baked into the client via `--setup` (`defineClientSetup`) | -| [zod](./zod) | CLI · `sdk`, `zod` | validating responses with generated zod schemas | -| [tanstack-query](./tanstack-query) | CLI · `sdk`, `tanstack-query` | React `useQuery(Options())` | -| [mock](./mock) | CLI · `sdk`, `mock` | MSW handlers from generated `handlers` | -| [programmatic](./programmatic) | `generateClient(...)` API | generating the client from a Node script | -| [package-runtime](./package-runtime) | CLI · `sdk`, package runtime | `runtime: package` — types + descriptors only; the versioned runtime is imported from `@redocly/client-generator`, fixes via `npm update` | -| [zero-install-quickstart](./zero-install-quickstart) | CLI · `sdk` | the first-touch loop: generate → import → call; one self-contained file, zero runtime dependencies | -| [node-native](./node-native) | CLI · `sdk` | `importExt: ts` — `.ts` import specifiers so plain `node src/main.ts` runs the client via Node's built-in type stripping | -| [configure-and-middleware](./configure-and-middleware) | CLI · `sdk` | `configure({ serverUrl, retry, fetch })`, `use()` targeting `ctx.operation` (literal unions), body mutation, auth setter, `ApiError.body` | -| [multi-instance](./multi-instance) | CLI · `sdk`, package runtime | per-tenant instances via `createClient(OPERATIONS)` — works in both runtimes; this example uses `runtime: package` | -| [sse-streaming](./sse-streaming) | CLI · `sdk` | typed `for await` over SSE, auto-reconnect via `Last-Event-ID` (`reconnectDelay`/`reconnect: false`), clean abort | -| [vendored-edge](./vendored-edge) | CLI · `sdk` | the generated file copied into a no-npm edge worker (`export default { fetch }`); `typescript` is the only dev tool | -| [pagination](./pagination) | CLI · `sdk` | auto-pagination from a `client.pagination` convention: `for await` over `.items()`/`.pages()` next to the unchanged one-shot call | -| [custom-pagination](./custom-pagination) | CLI · `sdk` | hand-written paging over the typed client for shapes the built-in styles don't cover (body cursors) | -| [custom-generator](./custom-generator) | CLI · `sdk` + custom generator | a local `generators` plugin emitting a `: 'METHOD /path'` route map next to the sdk | -| [ast-toolkit-generator](./ast-toolkit-generator) | CLI · `sdk` + custom generator | a plugin rendering real TypeScript types via `@redocly/client-generator/generate` (`tsType`) — a typed response-shape map | -| [nested-facade](./nested-facade) | CLI · `sdk` + custom generator | `api..` facade derived from the spec's tags by a plugin — regenerates with the spec | -| [cli](./cli) | CLI · `sdk`, `zod`, `cli` | a bin-ready command-line interface over the client: typed flags, `--json` bodies, `--dry-run`, a documented exit-code contract | -| [python-sdk](./python-sdk) | CLI · `python` | a full Python SDK (httpx): typed dataclasses, sync/async clients, pagination iterators | -| [go-sdk](./go-sdk) | CLI · `go` | a full Go SDK (stdlib-only): typed structs, `(T, error)` methods, `context.Context` | -| [php-sdk](./php-sdk) | CLI · `php` | a full PHP SDK (curl extension): promoted-constructor classes, native enums, named-argument methods | -| [ejected-generator](./ejected-generator) | CLI · ejected `php` | `eject-generator php` vendored + customized: the path entry shadows the built-in name; regeneration keeps the customization | +| Example | How it's generated | Shows | +| ---------------------------------------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | +| [fetch-functions](./fetch-functions) | CLI · `sdk`, functions | free functions + `ApiError` | +| [baked-setup](./baked-setup) | CLI · `sdk`, functions | publisher defaults baked into the client via `--setup` (`defineClientSetup`) | +| [zod](./zod) | CLI · `sdk`, `zod` | validating responses with generated zod schemas | +| [tanstack-query](./tanstack-query) | CLI · `sdk`, `tanstack-query` | React `useQuery(Options())` | +| [mock](./mock) | CLI · `sdk`, `mock` | MSW handlers from generated `handlers` | +| [programmatic](./programmatic) | `generateClient(...)` API | generating the client from a Node script | +| [package-runtime](./package-runtime) | CLI · `sdk`, package runtime | `runtime: package` — types + descriptors only; the versioned runtime is imported from `@redocly/client-generator`, fixes via `npm update` | +| [zero-install-quickstart](./zero-install-quickstart) | CLI · `sdk` | the first-touch loop: generate → import → call; one self-contained file, zero runtime dependencies | +| [node-native](./node-native) | CLI · `sdk` | `importExt: ts` — `.ts` import specifiers so plain `node src/main.ts` runs the client via Node's built-in type stripping | +| [configure-and-middleware](./configure-and-middleware) | CLI · `sdk` | `configure({ serverUrl, retry, fetch })`, `use()` targeting `ctx.operation` (literal unions), body mutation, auth setter, `ApiError.body` | +| [multi-instance](./multi-instance) | CLI · `sdk`, package runtime | per-tenant instances via `createClient(OPERATIONS)` — works in both runtimes; this example uses `runtime: package` | +| [sse-streaming](./sse-streaming) | CLI · `sdk` | typed `for await` over SSE, auto-reconnect via `Last-Event-ID` (`reconnectDelay`/`reconnect: false`), clean abort | +| [vendored-edge](./vendored-edge) | CLI · `sdk` | the generated file copied into a no-npm edge worker (`export default { fetch }`); `typescript` is the only dev tool | +| [pagination](./pagination) | CLI · `sdk` | auto-pagination from a `client.pagination` convention: `for await` over `.items()`/`.pages()` next to the unchanged one-shot call | +| [custom-pagination](./custom-pagination) | CLI · `sdk` | hand-written paging over the typed client for shapes the built-in styles don't cover (body cursors) | +| [custom-generator](./custom-generator) | CLI · `sdk` + custom generator | a local `generators` plugin emitting a `: 'METHOD /path'` route map next to the sdk | +| [typescript-types-generator](./typescript-types-generator) | CLI · `sdk` + custom generator | a plugin rendering real TypeScript types via `@redocly/client-generator/generate` (`tsType`) — a typed response-shape map | +| [nested-facade](./nested-facade) | CLI · `sdk` + custom generator | `api..` facade derived from the spec's tags by a plugin — regenerates with the spec | +| [cli](./cli) | CLI · `sdk`, `zod`, `cli` | a bin-ready command-line interface over the client: typed flags, `--json` bodies, `--dry-run`, a documented exit-code contract | +| [python-sdk](./python-sdk) | CLI · `python` | a full Python SDK (httpx): typed dataclasses, sync/async clients, pagination iterators | +| [go-sdk](./go-sdk) | CLI · `go` | a full Go SDK (stdlib-only): typed structs, `(T, error)` methods, `context.Context` | +| [php-sdk](./php-sdk) | CLI · `php` | a full PHP SDK (curl extension): promoted-constructor classes, native enums, named-argument methods | +| [ejected-generator](./ejected-generator) | CLI · ejected `php` | `eject-generator php` vendored + customized: the path entry shadows the built-in name; regeneration keeps the customization | ## Run one diff --git a/tests/e2e/generate-client/examples/ast-toolkit-generator/.gitignore b/tests/e2e/generate-client/examples/typescript-types-generator/.gitignore similarity index 100% rename from tests/e2e/generate-client/examples/ast-toolkit-generator/.gitignore rename to tests/e2e/generate-client/examples/typescript-types-generator/.gitignore diff --git a/tests/e2e/generate-client/examples/ast-toolkit-generator/README.md b/tests/e2e/generate-client/examples/typescript-types-generator/README.md similarity index 80% rename from tests/e2e/generate-client/examples/ast-toolkit-generator/README.md rename to tests/e2e/generate-client/examples/typescript-types-generator/README.md index ae939d258b..f7e64e70a3 100644 --- a/tests/e2e/generate-client/examples/ast-toolkit-generator/README.md +++ b/tests/e2e/generate-client/examples/typescript-types-generator/README.md @@ -1,9 +1,9 @@ -# AST toolkit generator example +# TypeScript types generator example -A custom generator that builds its output as a real TypeScript AST with the -`@redocly/client-generator/generate` entry — the same type-rendering toolkit the built-in -generators use — instead of concatenating strings -(compare with the string-building [`custom-generator`](../custom-generator) example). +A custom generator that renders real TypeScript types with the +`@redocly/client-generator/generate` entry — the same type renderer the built-in generators +use, so the mapping matches the generated client exactly, instead of guessing at type text +(compare with the plain string-building [`custom-generator`](../custom-generator) example). - [`response-map-generator.mjs`](./response-map-generator.mjs) — the generator. For every operation with a JSON success response it derives the response body's TypeScript type diff --git a/tests/e2e/generate-client/examples/ast-toolkit-generator/package.json b/tests/e2e/generate-client/examples/typescript-types-generator/package.json similarity index 81% rename from tests/e2e/generate-client/examples/ast-toolkit-generator/package.json rename to tests/e2e/generate-client/examples/typescript-types-generator/package.json index 7c58fdcb86..bae6b1c41e 100644 --- a/tests/e2e/generate-client/examples/ast-toolkit-generator/package.json +++ b/tests/e2e/generate-client/examples/typescript-types-generator/package.json @@ -1,5 +1,5 @@ { - "name": "@redocly-examples/ast-toolkit-generator", + "name": "@redocly-examples/typescript-types-generator", "private": true, "version": "0.0.0", "type": "module", diff --git a/tests/e2e/generate-client/examples/ast-toolkit-generator/redocly.yaml b/tests/e2e/generate-client/examples/typescript-types-generator/redocly.yaml similarity index 92% rename from tests/e2e/generate-client/examples/ast-toolkit-generator/redocly.yaml rename to tests/e2e/generate-client/examples/typescript-types-generator/redocly.yaml index 5beeef9dea..e5fee3b2bc 100644 --- a/tests/e2e/generate-client/examples/ast-toolkit-generator/redocly.yaml +++ b/tests/e2e/generate-client/examples/typescript-types-generator/redocly.yaml @@ -2,7 +2,7 @@ # The client is generated for the api that declares a `client` block # (run `redocly generate-client` with no args to build every such api). apis: - ast-toolkit-generator: + typescript-types-generator: root: ../_shared/cafe.yaml clientOutput: ./src/api/client.ts client: diff --git a/tests/e2e/generate-client/examples/ast-toolkit-generator/response-map-generator.mjs b/tests/e2e/generate-client/examples/typescript-types-generator/response-map-generator.mjs similarity index 100% rename from tests/e2e/generate-client/examples/ast-toolkit-generator/response-map-generator.mjs rename to tests/e2e/generate-client/examples/typescript-types-generator/response-map-generator.mjs diff --git a/tests/e2e/generate-client/examples/ast-toolkit-generator/src/main.ts b/tests/e2e/generate-client/examples/typescript-types-generator/src/main.ts similarity index 100% rename from tests/e2e/generate-client/examples/ast-toolkit-generator/src/main.ts rename to tests/e2e/generate-client/examples/typescript-types-generator/src/main.ts diff --git a/tests/e2e/generate-client/examples/ast-toolkit-generator/tsconfig.json b/tests/e2e/generate-client/examples/typescript-types-generator/tsconfig.json similarity index 100% rename from tests/e2e/generate-client/examples/ast-toolkit-generator/tsconfig.json rename to tests/e2e/generate-client/examples/typescript-types-generator/tsconfig.json From 485586c90b2c19620f3874a3cd1adff4d971fb70 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Thu, 6 Aug 2026 11:59:29 +0300 Subject: [PATCH 109/211] test: keep the e2e bars aligned with the new CLI prerequisites and rename wording --- .../.claude/skills/client-generators/SKILL.md | 6 ++++-- .../e2e/generate-client/identifier-injection.test.ts | 2 +- tests/e2e/generate-client/large-descriptions.test.ts | 12 +++++++++--- 3 files changed, 14 insertions(+), 6 deletions(-) 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 1885959c57..aa5372fecd 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 @@ -111,8 +111,10 @@ authored with exactly this toolkit and nothing else — models via `flattenAllOf `enumValues`/`discriminatorCases`, all code through `Printer`, every name through `identifierFor(..., RESERVED_WORDS.python)`. -TypeScript-emitting generators may additionally use the TS toolkit from -`@redocly/client-generator/generate` (`ts`, `printStatements`, `schemaToTypeNode`, …). +A generator that emits TypeScript may additionally use the source-text renderers from +`@redocly/client-generator/generate` — `tsType` (schema → type), `tsJsdoc`, `codeLiteral`, +`operationSignature`, `pascalCase`, `safeIdent`. There is no AST toolkit and no +`typescript` dependency: every generator prints text through `Printer`. ## The loop diff --git a/tests/e2e/generate-client/identifier-injection.test.ts b/tests/e2e/generate-client/identifier-injection.test.ts index af9abed7e4..ef4140095b 100644 --- a/tests/e2e/generate-client/identifier-injection.test.ts +++ b/tests/e2e/generate-client/identifier-injection.test.ts @@ -64,7 +64,7 @@ describe('generate-client identifier / comment injection', () => { ); expect(res.status, res.stderr).toBe(0); // The unsafe operationId is reported and rewritten, not silently accepted. - expect(res.stderr).toMatch(/is not a valid TypeScript identifier/); + expect(res.stderr).toMatch(/is not a usable identifier/); const src = readFileSync(entry, 'utf-8'); // No live comment-breakout: the payload's `*/` is neutralized to `*\/`. diff --git a/tests/e2e/generate-client/large-descriptions.test.ts b/tests/e2e/generate-client/large-descriptions.test.ts index c61f73dfe7..6c5c0d5383 100644 --- a/tests/e2e/generate-client/large-descriptions.test.ts +++ b/tests/e2e/generate-client/large-descriptions.test.ts @@ -6,7 +6,7 @@ // CI spreads it across shards like any other suite. import { spawnSync } from 'node:child_process'; -import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, writeFileSync, symlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -57,10 +57,17 @@ function typescriptBar(description: string): void { strictTypecheck(dir); } -/** CLI bar: the generated `.cli.ts` passes a strict, Node-typed `tsc --noEmit`. */ +/** + * CLI bar: the generated `.cli.ts` passes a strict, Node-typed `tsc --noEmit`. + * Selecting `cli` also emits the zod module it validates with, so the resolver needs a + * path to `zod` — taken from the repo, like `@types/node` below. + */ function cliBar(description: string): void { const dir = generateWith(['sdk', 'cli'], description); writeFileSync(join(dir, 'package.json'), JSON.stringify({ type: 'module' }), 'utf-8'); + // The temp dir sits outside the repo, so node resolution finds nothing: borrow the + // repo's node_modules for `zod` (the CLI's validation) and `@types/node`. + symlinkSync(join(repoRoot, 'node_modules'), join(dir, 'node_modules'), 'dir'); writeFileSync( join(dir, 'tsconfig.json'), JSON.stringify({ @@ -73,7 +80,6 @@ function cliBar(description: string): void { noEmit: true, skipLibCheck: true, types: ['node'], - // The temp dir has no node_modules; resolve @types/node from the repo. typeRoots: [join(repoRoot, 'node_modules/@types')], }, include: ['**/*.ts'], From 12bebc50c21e54e2d02fb57e9fe57c5ea8385290 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Thu, 6 Aug 2026 13:02:16 +0300 Subject: [PATCH 110/211] test: give client generation its own suite and CI job --- .claude/rules/testing.md | 2 + .github/workflows/tests.yaml | 38 +++++++++++++++---- AGENTS.md | 6 ++- CONTRIBUTING.md | 22 +++++++++++ package.json | 1 + tests/e2e/generate-client/base.test.ts | 5 +-- tests/e2e/generate-client/cafe.test.ts | 5 +-- tests/e2e/generate-client/cli.test.ts | 13 +++++-- tests/e2e/generate-client/helpers.ts | 19 ++++++++++ .../e2e/generate-client/package-mode.test.ts | 12 +++--- tests/e2e/generate-client/pagination.test.ts | 7 ++-- vitest.config.ts | 16 ++++++++ 12 files changed, 116 insertions(+), 30 deletions(-) diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 722c27c7b4..e6e07935cb 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -7,5 +7,7 @@ 1. Compile before testing. Unit tests import from `lib/` (compiled output), not `src/` — run `npm run compile` after every change. 1. Run the full suite (`npm test`) when you touch core linting logic, and make sure all tests pass in CI. +1. Client generation has its own suite: `npm run generators` runs the client-generator unit tests plus the `tests/e2e/generate-client` bars (which compile real Python/Go/PHP/TypeScript output). + Run it for any generation change; `npm run e2e` no longer includes those tests. 1. Coverage thresholds (`vitest.config.ts`) are a guide, not a number to game. If a feature or fix is already covered by e2e tests, propose lowering the threshold rather than padding the suite with unit tests that only chase coverage. diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 7f4218f2a8..c9c4b58a4e 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -37,16 +37,36 @@ jobs: uses: davelosert/vitest-coverage-report-action@d63aa97db4c0319f304f1787689de1ca548365cf # v2.11.1 e2e: - # The e2e suite is split across shards so no single runner carries the whole set. - # Running all suites in one step was cancelled mid-run by the Actions service once the - # generate-client suites grew past ~28 (a healthy runner, no resource exhaustion); - # each shard stays well under that. Three shards absorb the large-descriptions suite - # (compile bars over big real-world descriptions — the heaviest single file). + # Everything under tests/e2e EXCEPT generate-client, which has its own job below. + # Split across shards so no single runner carries the whole set: running all suites in + # one step was cancelled mid-run by the Actions service once the suite count grew past + # ~28 (a healthy runner, no resource exhaustion), and each shard stays well under that. runs-on: ubuntu-latest strategy: fail-fast: false matrix: shard: [1, 2, 3] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 + with: + node-version: 24 + cache: npm + - name: Install dependencies + run: npm ci + - name: E2E Tests (shard ${{ matrix.shard }}/3) + run: npm run e2e -- --shard=${{ matrix.shard }}/3 + + generators: + # Client generation has its own job: its bars compile real Python, Go, PHP, and + # TypeScript output from generated clients (including big real-world descriptions), so + # they are the slowest tests we have and they need toolchains nothing else does. + # Keeping them here means adding another language bar cannot slow the shared e2e job. + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [1, 2] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 @@ -56,8 +76,10 @@ jobs: - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: '3.12' - - name: Install httpx (the large-descriptions Python import bar needs it) + - name: Install httpx (the Python import bars need it) run: pip install httpx + # Go, gofmt, and php come with the runner image; a bar whose toolchain is missing + # skips itself, so a thinner image degrades coverage instead of failing the job. - name: Cache the pinned GitHub REST description uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4 with: @@ -65,8 +87,8 @@ jobs: key: large-descriptions-${{ hashFiles('tests/e2e/generate-client/large-descriptions.test.ts') }} - name: Install dependencies run: npm ci - - name: E2E Tests (shard ${{ matrix.shard }}/3) - run: npm run e2e -- --shard=${{ matrix.shard }}/3 + - name: Generator Tests (shard ${{ matrix.shard }}/2) + run: npm run generators -- --shard=${{ matrix.shard }}/2 examples: # The examples gitignore their generated clients (only zero-install-quickstart commits diff --git a/AGENTS.md b/AGENTS.md index 63cf5546e6..c1d3e21337 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,9 +46,12 @@ npm run unit -- -t 'test name pattern' # Update snapshots npm run unit -- -u -# Run e2e tests +# Run e2e tests (everything under tests/e2e except generate-client) npm run e2e +# Run every generator test (client-generator unit + generate-client e2e) +npm run generators + # Run the full test suite (compile + typecheck + unit + e2e) npm test @@ -104,6 +107,7 @@ Naming and reuse: - A `redocly.yaml` in the repository root affects unit tests in the CLI package. Remove it before running them. - Run the full suite (`npm test`) when you touch core linting logic. +- Run `npm run generators` when you touch client generation — it is the whole generator suite in one command. The full testing and QA rules are in [`.claude/rules/testing.md`](./.claude/rules/testing.md). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9351364581..b85d62e0dc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -238,6 +238,28 @@ Note that the snapshot does not always match the command output because of the w This is intentional so outputs stay consistent for snapshot testing. The order of stdout and stderr in a snapshot may differ from what you see in the terminal, but the combined output is stable. +### Generator tests + +Client generation has its own suite: `npm run generators` runs the `@redocly/client-generator` unit tests together with the `tests/e2e/generate-client` end-to-end tests, so one command covers everything about generation. + +```bash +npm run generators # every generator test +npm run generators -- tests/e2e/generate-client/go.test.ts # one file +npm run generators -- -t 'gofmt' # by test name +``` + +Those e2e tests compile their output with real toolchains, so what is available decides what runs: + +- **Python** (`python3`, plus `httpx` for the import bars) and **Go** (`go build`, `go vet`, `gofmt`) and **PHP** (`php -l`) — a bar for a missing toolchain skips itself rather than failing, so a partial local setup still gives a useful run. CI installs Python and `httpx`; Go and PHP come with the runner image. +- The largest bars generate from big real-world descriptions (Rebilly, the GitHub REST API), which is why they are slow and why the suite has its own CI job — a growing set of compiled-language bars must not slow the shared e2e job. + +`npm run e2e` covers everything under `tests/e2e/` **except** `generate-client`. +`npm run unit` still includes the client-generator unit tests, so the coverage report stays whole. + +Several of these tests run a local HTTP server and assert on its request log. +On a machine with many cores, vitest runs enough of them in parallel to occasionally reset a connection — a failure that says nothing about the code under test. +Reading a server's log goes through `serverLog()` in `tests/e2e/generate-client/helpers.ts`, which retries for that reason; if you see an isolated `ECONNRESET` or `fetch failed`, re-run the file before investigating. + ### Smoke tests Smokes are for testing the CLI in different environments. diff --git a/package.json b/package.json index 9edaef6c7b..9b4dd91343 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test": "npm run compile && npm run typecheck && npm run unit && npm run e2e", "unit": "VITEST_SUITE=unit vitest run", "e2e": "VITEST_SUITE=e2e vitest run", + "generators": "VITEST_SUITE=generators vitest run", "smoke:rebilly": "VITEST_SUITE=smoke-rebilly vitest run", "format": "oxfmt .", "format:check": "oxfmt --check .", diff --git a/tests/e2e/generate-client/base.test.ts b/tests/e2e/generate-client/base.test.ts index fe38760c57..cc75f0a096 100644 --- a/tests/e2e/generate-client/base.test.ts +++ b/tests/e2e/generate-client/base.test.ts @@ -3,7 +3,7 @@ import { existsSync, readFileSync, rmSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { generate, killServer, repoRoot, startServer } from './helpers.js'; +import { generate, killServer, repoRoot, startServer, serverLog } from './helpers.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const fixture = join(__dirname, 'fixtures/base.yaml'); @@ -110,8 +110,7 @@ describe('generate-client base consumer (single-file output)', () => { // Bucket C round-trip: createPet ran with a body that omits the readOnly `id`. expect(typeof parsed.created.name).toBe('string'); - const logResponse = await fetch(`${SERVER_BASE}/__test__/log`); - const log = (await logResponse.json()) as Array<{ method: string; url: string }>; + const log = await serverLog>(SERVER_BASE); expect(log).toContainEqual({ method: 'GET', url: '/pets/1' }); expect(log).toContainEqual({ method: 'POST', url: '/pets' }); expect( diff --git a/tests/e2e/generate-client/cafe.test.ts b/tests/e2e/generate-client/cafe.test.ts index f204116807..0722c4d76e 100644 --- a/tests/e2e/generate-client/cafe.test.ts +++ b/tests/e2e/generate-client/cafe.test.ts @@ -3,7 +3,7 @@ import { existsSync, readFileSync, rmSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { cliEntry, killServer, repoRoot, startServer } from './helpers.js'; +import { cliEntry, killServer, repoRoot, startServer, serverLog } from './helpers.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const fixture = join(__dirname, 'fixtures/cafe.yaml'); @@ -106,8 +106,7 @@ describe('generate-client end-to-end (cafe.yaml)', () => { } results = JSON.parse(run.stdout.trim()) as StepResult[]; - const logResponse = await fetch(`${SERVER_BASE}/__test__/log`); - log = (await logResponse.json()) as LogEntry[]; + log = await serverLog(SERVER_BASE); }, 120_000); afterAll(async () => { diff --git a/tests/e2e/generate-client/cli.test.ts b/tests/e2e/generate-client/cli.test.ts index 38e05491be..0c8f37d6d4 100644 --- a/tests/e2e/generate-client/cli.test.ts +++ b/tests/e2e/generate-client/cli.test.ts @@ -3,7 +3,13 @@ import { existsSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { generate, killServer, startServer, tsxBin } from './helpers.js'; +import { + generate, + killServer, + serverLog as readServerLog, + startServer, + tsxBin, +} from './helpers.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const fixture = join(__dirname, 'fixtures/cli.yaml'); @@ -28,11 +34,10 @@ function runCliBin(args: string[], env: Record = {}) { return { code: result.status, stdout: result.stdout, stderr: result.stderr }; } -async function serverLog(): Promise< +function serverLog(): Promise< Array<{ method: string; url: string; authorization?: string; body?: string }> > { - const response = await fetch(`${SERVER_BASE}/__test__/log`); - return response.json(); + return readServerLog(SERVER_BASE); } describe('generate-client cli generator (end-to-end)', () => { diff --git a/tests/e2e/generate-client/helpers.ts b/tests/e2e/generate-client/helpers.ts index 00ac8fd48b..b2b5e270b5 100644 --- a/tests/e2e/generate-client/helpers.ts +++ b/tests/e2e/generate-client/helpers.ts @@ -114,6 +114,25 @@ export async function waitForServerReady( ); } +/** + * Read a test server's request log. The fetch itself retries: a loaded machine (the + * generator suite compiles Go, PHP, and TypeScript in parallel) occasionally resets a + * connection to the local server, which says nothing about the client under test. + */ +export async function serverLog>>(baseUrl: string): Promise { + let lastError: unknown; + for (let attempt = 0; attempt < 3; attempt++) { + try { + const response = await fetch(`${baseUrl}/__test__/log`); + return (await response.json()) as T; + } catch (error) { + lastError = error; + await new Promise((resolveFn) => setTimeout(resolveFn, 100)); + } + } + throw lastError; +} + export function killServer(server: ChildProcess): Promise { return new Promise((resolveFn) => { if (!server.pid || server.exitCode !== null) { diff --git a/tests/e2e/generate-client/package-mode.test.ts b/tests/e2e/generate-client/package-mode.test.ts index 3de4b3a1d3..1f1adfe260 100644 --- a/tests/e2e/generate-client/package-mode.test.ts +++ b/tests/e2e/generate-client/package-mode.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { cliEntry, generate, killServer, repoRoot, startServer } from './helpers.js'; +import { cliEntry, generate, killServer, repoRoot, startServer, serverLog } from './helpers.js'; // The `runtime: package` output: instead of inlining the runtime, the generated // client imports `createClient` from `@redocly/client-generator` (resolved through @@ -115,12 +115,10 @@ describe('generate-client package-runtime consumer', () => { 'streamEvents', ]); - const logResponse = await fetch(`${SERVER_BASE}/__test__/log`); - const log = (await logResponse.json()) as Array<{ - method: string; - url: string; - auth: string | null; - }>; + const log = await serverLog>( + SERVER_BASE + ); + // Wire-name path substitution + query serialization + injected bearer. expect(log).toContainEqual({ method: 'GET', diff --git a/tests/e2e/generate-client/pagination.test.ts b/tests/e2e/generate-client/pagination.test.ts index 021ff3bbac..733b47ac15 100644 --- a/tests/e2e/generate-client/pagination.test.ts +++ b/tests/e2e/generate-client/pagination.test.ts @@ -3,7 +3,7 @@ import { existsSync, readFileSync, rmSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; -import { killServer, repoRoot, startServer } from './helpers.js'; +import { killServer, repoRoot, startServer, serverLog } from './helpers.js'; // Auto-pagination end to end, over a live server: the `x-redoclyPagination` extension arm // (cursor style — three pages, resume, abort) generated with NO config, the @@ -39,9 +39,8 @@ async function resetLog(): Promise { expect(response.ok).toBe(true); } -async function fetchLog(): Promise> { - const response = await fetch(`${SERVER_BASE}/__test__/log`); - return (await response.json()) as Array<{ method: string; url: string }>; +function fetchLog(): Promise> { + return serverLog>(SERVER_BASE); } function runConsumer(script: string): { stdout: string } { diff --git a/vitest.config.ts b/vitest.config.ts index a79c631097..cbc87edd47 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -32,6 +32,22 @@ const configExtension: { [key: string]: ViteUserConfig } = { e2e: defineConfig({ test: { include: ['tests/e2e/**/*.test.ts'], + // Client generation has its own suite and its own CI job (see `generators` below): + // its bars compile real Python/Go/PHP/TypeScript output, so a growing set of them + // must not slow the job everything else shares. + exclude: ['tests/e2e/generate-client/**'], + }, + }), + // Everything about client generation in one command: the package's unit tests plus the + // end-to-end bars. The unit tests also run under `unit`, which keeps the coverage report + // whole — they are seconds, and being able to run the whole generator surface at once is + // worth that. + generators: defineConfig({ + test: { + include: [ + 'packages/client-generator/src/**/*.test.ts', + 'tests/e2e/generate-client/**/*.test.ts', + ], }, }), 'smoke-rebilly': defineConfig({ From 27f008c00ee16b49db1550759c2cef49322e1ec8 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Thu, 6 Aug 2026 14:48:40 +0300 Subject: [PATCH 111/211] docs: condense the client-generation changeset to one sentence --- .changeset/agent-friendly-generators.md | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index 66b6ced09f..ed6d41688a 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -3,22 +3,6 @@ '@redocly/cli': minor --- -Added agent-friendly client generation: built-in `python`, `go`, `php`, and `cli` generators, a language-neutral authoring toolkit, an `eject-generator` command that vendors any built-in generator into your repo together with its design as an agent skill in `.claude/skills/`, and verification against large real-world descriptions. +Added agent-friendly client generation: `python`, `go`, `php`, and `cli` generators beside the TypeScript ones, a language-neutral authoring toolkit with per-generator options, and an `eject-generator` command that vendors any built-in generator — plus its design as an agent skill — into your repo. -Selecting a generator now pulls in the generators it depends on: `--generator cli` emits the sdk and zod modules it needs (so the generated CLI validates requests by default and requires `zod` at run time), and `--generator tanstack-query` emits the sdk it wraps. - -Added `goPackage` (`--go-package`) to set the package clause of the `go` generator's output, and `--bin-name` as the flag form of `binName`. - -A custom generator can now declare its own options as a schema; publishers set them under `client.options.` and the values are validated — unknown key, wrong type, value outside an `enum`, missing required option — before anything is written, with defaults applied when `run` receives them. - -**Note:** the per-operation pagination extension is now `x-redoclyPagination`, matching the camelCase of every other Redocly extension. Rename it in descriptions that declared `x-redocly-pagination`; the old spelling is no longer read. - -`eject-generator` now wires itself up: it records `@redocly/client-generator` in your `devDependencies` and adds the ejected file to `client.generators`, printing the snippet to add by hand only when the configuration file has a shape it won't edit blind. - -Generator compatibility is the package version under semver instead of a separate contract number: a generator declares the range it was written against with `requiresGenerator` (`^1.2.0`, `~1.2.0`, `>=1.2.0`, or an exact version), and a CLI outside that range says which version it ships and how to fix it. `GENERATOR_CONTRACT` is gone; ejected generators record the range for you. - -`eject-generator --update` no longer needs a committed `.pristine/` snapshot: the merge base is the version recorded in the ejected file's own header, fetched from the registry when it differs from the installed one. An existing `.pristine/` copy is still used as the base and can then be deleted. - -Every built-in generator is now ejectable, not just the language SDKs: a TypeScript generator (`sdk`, `zod`, `mock`, `cli`, `swr`, `tanstack-query`, `transformers`) ships bundled with the emitters it uses — one unminified `.mjs` you own that produces byte-identical output until you change it. The `tanstack-query-vue`/`-svelte`/`-solid` variants point at `tanstack-query`, whose framework is one argument in the ejected file. - -There is one way to author a generator: printing text with the language-neutral toolkit. The AST-era exports (`ts`, `printStatements`, `parseStatements`, `schemaToTypeNode`) are gone; TypeScript generators use the source-text renderers (`tsType`, `tsJsdoc`, `codeLiteral`, `operationSignature`). `typescript` stays an optional peer dependency needed only to bake a `--setup` module. +**Note:** the per-operation pagination extension is now `x-redoclyPagination`; rename it in descriptions that used `x-redocly-pagination`, which is no longer read. From d345a7bc17e7f731f256ef9a3684f1abe2e7cb3a Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Thu, 6 Aug 2026 15:52:09 +0300 Subject: [PATCH 112/211] docs: use an imperative heading for the ejected-generator update section --- docs/@v2/commands/eject-generator.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/@v2/commands/eject-generator.md b/docs/@v2/commands/eject-generator.md index 1217f4b20b..e804f8613e 100644 --- a/docs/@v2/commands/eject-generator.md +++ b/docs/@v2/commands/eject-generator.md @@ -52,7 +52,7 @@ client: An ejected-unmodified generator produces byte-identical output to the built-in. To roll back, delete the file and the config line. -## Updating an ejected generator +## Update an ejected generator `redocly eject-generator --update` merges the version shipped by your installed `@redocly/client-generator` into your copy. The three-way merge uses the version recorded in the ejected file's header as the common ancestor, so nothing extra needs to be committed and there is no snapshot to keep in sync. From c41a00041c8443b32e1daecf30a7d2ca94511d7e Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Thu, 6 Aug 2026 16:59:47 +0300 Subject: [PATCH 113/211] fix: clean changelog --- .changeset/agent-friendly-generators.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index ed6d41688a..c70b93cae6 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -4,5 +4,3 @@ --- Added agent-friendly client generation: `python`, `go`, `php`, and `cli` generators beside the TypeScript ones, a language-neutral authoring toolkit with per-generator options, and an `eject-generator` command that vendors any built-in generator — plus its design as an agent skill — into your repo. - -**Note:** the per-operation pagination extension is now `x-redoclyPagination`; rename it in descriptions that used `x-redocly-pagination`, which is no longer read. From 05666b036f6d4509c6ee221973cbe61f3cd25330 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Thu, 6 Aug 2026 17:31:13 +0300 Subject: [PATCH 114/211] fix(cli): stop inlining a stale generator list in --generator help --- docs/@v2/guides/use-generated-client.md | 21 ++++++++++++++++++++- packages/cli/src/index.ts | 2 +- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index 23d5d2508c..c603e1f2f4 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -19,6 +19,7 @@ Incompatible selections fail fast with an explanation. | `mock` | `.mocks.ts` — [MSW](https://mswjs.io) v2 handlers + `create` factories. | `msw` `^2` (+ `@faker-js/faker` for `--mock-data faker`) | | `transformers` | `.transformers.ts` — `transform` functions that parse wire dates to `Date`. | none | | `cli` | `.cli.ts` — a bin-ready [command-line interface](#generated-cli) over the client: typed flags, `--json` bodies, env auth, `--page-all`. | none | +| `cli-docs` | `.cli.md` — a Markdown [reference for the generated CLI](#cli-reference-docs): every command, flag, exit code, and credential variable. | none | ```sh redocly generate-client openapi.yaml --output src/client.ts --generator sdk --generator zod --generator mock @@ -61,7 +62,25 @@ Exit codes are a documented contract, and errors print one JSON object to stderr To ship it as a real bin, compile with `tsc` and point `package.json`'s `bin` at the compiled file. -The CLI can also emit its own reference documentation as Markdown (every command, flag, and exit code) — planned next, and then for the language SDKs too. +#### CLI reference docs + +The `cli-docs` generator writes `.cli.md`: a Markdown reference with the usage line, the global flags, the credential environment variables, the exit-code table, and one section per command listing its positionals and flags with types, defaults, and descriptions. +It renders from the same command table the CLI dispatches on, so the page cannot drift from the tool it documents — regenerate and the docs follow. +Selecting it pulls in the CLI it describes, so `--generator cli-docs` is enough. + +```sh +redocly generate-client openapi.yaml --output src/client.ts --generator cli-docs +``` + +Two options shape the page, under `client.options.cli-docs`: + +| Option | Type | Description | +| ------------- | ------- | --------------------------------------------------------------------------------------------------- | +| `title` | string | Page heading. Defaults to ` CLI`. | +| `frontmatter` | boolean | Emit YAML front matter (`title`) above the heading, for docs sites that expect it. Default `false`. | + +For a different structure or wording, [eject the generator](../commands/eject-generator.md) — the renderer is the template, so `redocly eject-generator cli-docs` hands you the page layout as code you own, with no template syntax to learn. +The same reference for the language SDKs is next. ### Language SDKs diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 76aef54991..59dbcc904a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -955,7 +955,7 @@ yargs(hideBin(process.argv)) }, generator: { describe: - 'Generator to run; repeat the flag to run several (default: sdk). A built-in name (sdk, zod, tanstack-query, swr, transformers, mock) or a custom-generator path/package specifier. Example: --generator sdk --generator zod', + 'Generator to run; repeat the flag to run several (default: sdk). A built-in generator name, or a path/package specifier for a custom one. The built-in list lives in the "Use the generated client" guide, so it stays in one place. Example: --generator sdk --generator zod', type: 'string', array: true, requiresArg: true, From 792f6e416ff1e323923500c83795671813e642c3 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Thu, 6 Aug 2026 23:19:44 +0300 Subject: [PATCH 115/211] feat: add a cli-docs generator that renders the generated CLI's Markdown reference --- .changeset/cli-docs-generator.md | 6 + packages/cli/src/commands/eject-generator.ts | 1 + .../skills/cli-docs-generator/SKILL.md | 58 +++++ .../scripts/generate-eject-assets.mjs | 1 + .../client-generator/src/emitters/cli-docs.ts | 203 ++++++++++++++++++ packages/client-generator/src/emitters/cli.ts | 17 +- .../src/emitters/runtime-sources.ts | 2 +- .../src/generators/__tests__/cli-docs.test.ts | 107 +++++++++ .../__tests__/generator-skills.test.ts | 11 +- .../src/generators/cli-docs/AGENTS.md | 52 +++++ .../src/generators/cli-docs/index.ts | 49 +++++ .../client-generator/src/generators/index.ts | 2 + .../client-generator/src/generators/meta.ts | 16 ++ .../client-generator/src/generators/types.ts | 1 + packages/client-generator/src/runtime/cli.ts | 4 +- tests/e2e/generate-client/cli-docs.test.ts | 95 ++++++++ 16 files changed, 617 insertions(+), 8 deletions(-) create mode 100644 .changeset/cli-docs-generator.md create mode 100644 packages/client-generator/eject-assets/skills/cli-docs-generator/SKILL.md create mode 100644 packages/client-generator/src/emitters/cli-docs.ts create mode 100644 packages/client-generator/src/generators/__tests__/cli-docs.test.ts create mode 100644 packages/client-generator/src/generators/cli-docs/AGENTS.md create mode 100644 packages/client-generator/src/generators/cli-docs/index.ts create mode 100644 tests/e2e/generate-client/cli-docs.test.ts diff --git a/.changeset/cli-docs-generator.md b/.changeset/cli-docs-generator.md new file mode 100644 index 0000000000..a808e92ac0 --- /dev/null +++ b/.changeset/cli-docs-generator.md @@ -0,0 +1,6 @@ +--- +'@redocly/client-generator': minor +'@redocly/cli': minor +--- + +Added a `cli-docs` generator that writes the Markdown reference for the generated CLI — every command, flag, credential variable, and exit code — rendered from the same command table the CLI dispatches on. diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts index 08e106b9c5..1a9e3cd297 100644 --- a/packages/cli/src/commands/eject-generator.ts +++ b/packages/cli/src/commands/eject-generator.ts @@ -36,6 +36,7 @@ const EJECTABLE = new Set([ 'tanstack-query', 'transformers', 'cli', + 'cli-docs', ]); /** diff --git a/packages/client-generator/eject-assets/skills/cli-docs-generator/SKILL.md b/packages/client-generator/eject-assets/skills/cli-docs-generator/SKILL.md new file mode 100644 index 0000000000..fef20f8bcc --- /dev/null +++ b/packages/client-generator/eject-assets/skills/cli-docs-generator/SKILL.md @@ -0,0 +1,58 @@ +--- +name: cli-docs-generator +description: Design of the ejected Redocly `cli-docs` client generator. Read it, and update it, before changing generators/cli-docs.mjs. +--- + +# The `cli-docs` generator — its skill + +This file is the DESIGN of your ejected `cli-docs` generator (`generators/cli-docs.mjs`): +**to change the generator, edit this skill first, then make the code match it** — a diff +to `generators/cli-docs.mjs` that has no covering sentence here is incomplete. + +## What it emits + +`.cli.md` — the Markdown reference for the generated CLI: the usage line, the +global flags, the credential environment variables, the exit-code table, and one section +per command with its positionals and flags (type, required, choices, description). + +## Design decisions that must hold + +- **One source of truth**: the page renders from `commandData(model, emit)` — the same + table `runCli` dispatches on — and from `groupSlug`/`envPrefix`, the same functions the + runtime addresses groups and reads credentials with. Documentation that derives from a + second model drifts from the tool the first time either side changes, so it never does + that. A new CLI capability shows up here only when it is in the command table. +- **Requires the `cli` generator** it documents: selecting `cli-docs` pulls in `cli` (and + through it `sdk` and `zod`), so `--generator cli-docs` is a complete, consistent set. +- **The renderer IS the template.** Publishers who need another structure eject this + generator rather than learning a template syntax — one customization mechanism, no + template engine, no new dependency. Light customization stays in declared options. +- **Declared options**: `title` (page heading, default ` CLI`) and + `frontmatter` (emit YAML front matter with the title, default `false`). Both are + validated by the pipeline before `run`, so the renderer reads them directly. +- **Markdown that survives a linter**: ATX headings, a blank line around every block, no + hard tabs, and one sentence per line in prose — generated docs land in repos that lint + Markdown in CI. +- **Escapes what descriptions contain**: a summary or description is arbitrary text, so + pipes are escaped inside table cells and newlines collapse to spaces. + +## Emitters that implement it + +`emitters/cli-docs.ts` (the page renderer), over `emitters/cli.ts`'s `commandData` and +the runtime's `groupSlug`/`envPrefix`. + +## Ejecting it + +`redocly eject-generator cli-docs` ships this generator BUNDLED with the emitter it uses — +one small `.mjs` you own, importing `@redocly/client-generator` and +`@redocly/openapi-core`. Change the sections, the wording, or the table columns, and +regenerate: this is the answer to "can the documentation templates be ejected too". + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Make `generators/cli-docs.mjs` match it. +3. Run `redocly generate-client` and inspect the `git diff` of the generated output — + generated files are never hand-edited. + +Newer built-in versions merge in with `redocly eject-generator cli-docs --update`. diff --git a/packages/client-generator/scripts/generate-eject-assets.mjs b/packages/client-generator/scripts/generate-eject-assets.mjs index 7c3f9d2ec7..3c0b244b90 100644 --- a/packages/client-generator/scripts/generate-eject-assets.mjs +++ b/packages/client-generator/scripts/generate-eject-assets.mjs @@ -100,6 +100,7 @@ const TYPESCRIPT = [ { name: 'swr', imports: ['swrGenerator'], run: 'swrGenerator' }, { name: 'transformers', imports: ['transformersGenerator'], run: 'transformersGenerator' }, { name: 'cli', imports: ['cliGenerator', 'cliSample'], run: 'cliGenerator', sample: 'cliSample' }, + { name: 'cli-docs', imports: ['cliDocsGenerator'], run: 'cliDocsGenerator' }, { name: 'tanstack-query', imports: ['tanstackQueryGenerator'], diff --git a/packages/client-generator/src/emitters/cli-docs.ts b/packages/client-generator/src/emitters/cli-docs.ts new file mode 100644 index 0000000000..4d3109234d --- /dev/null +++ b/packages/client-generator/src/emitters/cli-docs.ts @@ -0,0 +1,203 @@ +// The cli-docs emitter: renders the Markdown reference for the generated CLI from the +// SAME command table `runCli` dispatches on, and the same `groupSlug`/`envPrefix` the +// runtime addresses groups and reads credentials with. A second model would drift from +// the tool the first time either side changed. + +import { Printer } from '../authoring/printer.js'; +import { envPrefix, groupSlug, type CliCommand, type CliFlag } from '../runtime/cli.js'; + +export type CliDocsOptions = { + /** Page heading. */ + title: string; + /** Emit YAML front matter carrying the title, for docs sites that expect it. */ + frontmatter: boolean; + /** The command name the CLI prints and derives its credential variables from. */ + binName: string; + /** Auth schemes the description declares, in the order the CLI resolves them. */ + schemes: Array<{ key: string; kind: 'bearer' | 'basic' | 'apiKey' }>; +}; + +/** Table-cell-safe text: one line, and pipes escaped so they don't open a column. */ +function cell(text: string | undefined): string { + return (text ?? '').replace(/\s+/g, ' ').trim().replace(/\|/g, '\\|'); +} + +/** How a command is typed at the prompt: ` `, or just ``. */ +function address(command: CliCommand): string { + return [command.group === undefined ? undefined : groupSlug(command.group), command.name] + .filter(Boolean) + .join(' '); +} + +function usageLine(binName: string, command: CliCommand): string { + const words = [ + binName, + address(command), + ...command.positionals.map((positional) => `<${positional.name}>`), + ...command.flags.filter((flag) => flag.required).map((flag) => `--${flag.name} <${flag.type}>`), + ...(command.body ? [command.body.required ? "--json ''" : "[--json '']"] : []), + ]; + return words.filter((word) => word !== '').join(' '); +} + +function writeFlagTable(printer: Printer, flags: CliFlag[]): void { + printer.line('| Flag | Type | Required | Description |'); + printer.line('| ---- | ---- | -------- | ----------- |'); + for (const flag of flags) { + const description = [ + cell(flag.description), + flag.enum === undefined + ? '' + : `One of ${flag.enum.map((value) => `\`${value}\``).join(', ')}.`, + flag.type === 'array' ? 'Repeat the flag for multiple values.' : '', + ] + .filter((part) => part !== '') + .join(' '); + printer.line( + `| \`--${flag.name}\` | ${flag.type} | ${flag.required ? 'yes' : 'no'} | ${description} |` + ); + } + printer.blank(); +} + +function writeCommand(printer: Printer, command: CliCommand, options: CliDocsOptions): void { + printer.line(`### \`${address(command)}\``); + printer.blank(); + if (command.summary !== undefined) { + printer.line(cell(command.summary)); + printer.blank(); + } + printer.line(`\`${command.method} ${command.path}\``); + printer.blank(); + printer.line('```sh'); + printer.line(usageLine(options.binName, command)); + printer.line('```'); + printer.blank(); + if (command.positionals.length > 0) { + printer.line('| Argument | Description |'); + printer.line('| -------- | ----------- |'); + for (const positional of command.positionals) { + printer.line(`| \`<${positional.name}>\` | ${cell(positional.description)} |`); + } + printer.blank(); + } + if (command.flags.length > 0) writeFlagTable(printer, command.flags); + const notes = [ + command.body === undefined + ? '' + : `Takes a JSON body${command.body.required ? ' (required)' : ''}: \`--json ''\`, \`--json @file.json\`, or \`--json @-\` for stdin.`, + command.paginated === true + ? 'Paginated: `--page-all` follows every page, printing one JSON page per line.' + : '', + command.sse === true ? 'Streams server-sent events as one JSON object per line.' : '', + command.blob === true ? 'Returns binary content, so `--output ` is required.' : '', + ].filter((note) => note !== ''); + for (const note of notes) printer.line(note); + if (notes.length > 0) printer.blank(); +} + +/** The whole page: heading, global flags, credentials, exit codes, then every command. */ +export function renderCliDocs(commands: CliCommand[], options: CliDocsOptions): string { + const printer = new Printer(); + if (options.frontmatter) { + printer.line('---'); + printer.line(`title: ${options.title}`); + printer.line('---'); + printer.blank(); + } + printer.line(`# ${options.title}`); + printer.blank(); + printer.line( + `Generated command-line reference for \`${options.binName}\`, produced from the API description by \`redocly generate-client\`.` + ); + printer.line('Re-run generation to update it — this file is not hand-edited.'); + printer.blank(); + + printer.line('## Usage'); + printer.blank(); + printer.line('```sh'); + printer.line(`${options.binName} [flags]`); + printer.line(`${options.binName} --help`); + printer.line(`${options.binName} schema # request/response schemas`); + printer.line('```'); + printer.blank(); + + printer.line('## Global flags'); + printer.blank(); + printer.line('| Flag | Description |'); + printer.line('| ---- | ----------- |'); + for (const [flag, description] of [ + ['--server-url ', 'Override the server URL included in the client.'], + ['--format ', 'Output format.'], + ['--dry-run', 'Print the prepared request, credentials redacted, without sending it.'], + ['--page-all', 'Follow pagination, printing one JSON page per line.'], + ['--output ', 'Write the response body to a file. Required for binary responses.'], + ['--token ', 'Bearer token, overriding the environment.'], + ['--json ', 'Request body, inline or from a file or stdin.'], + ] as const) { + printer.line(`| \`${flag}\` | ${description} |`); + } + printer.blank(); + + const prefix = envPrefix(options.binName); + printer.line('## Credentials'); + printer.blank(); + if (options.schemes.length === 0) { + printer.line('The description declares no security schemes, so no credentials are read.'); + } else { + printer.line('Credentials come from the environment:'); + printer.blank(); + printer.line('| Scheme | Variable |'); + printer.line('| ------ | -------- |'); + for (const scheme of options.schemes) { + const variable = + scheme.kind === 'bearer' + ? `\`${prefix}_TOKEN\` (or \`--token\`)` + : scheme.kind === 'basic' + ? `\`${prefix}_USERNAME\` and \`${prefix}_PASSWORD\`` + : `\`${prefix}_API_KEY_${envPrefix(scheme.key)}\``; + printer.line(`| ${scheme.kind} (\`${scheme.key}\`) | ${variable} |`); + } + } + printer.blank(); + + printer.line('## Exit codes'); + printer.blank(); + printer.line('| Code | Meaning |'); + printer.line('| ---- | ------- |'); + for (const [code, meaning] of [ + [0, 'success'], + [1, 'API error (status other than 401 or 403)'], + [2, 'auth error (401 or 403)'], + [3, 'validation error'], + [4, 'usage error (unknown command or flag, bad `--json`)'], + ] as const) { + printer.line(`| ${code} | ${meaning} |`); + } + printer.blank(); + printer.line('Errors print one JSON object to stderr, so stdout stays clean for piping.'); + printer.blank(); + + // One section per tag, in the order the description declares them, then the untagged + // commands — the same order `--help` lists them in. + const groups = [...new Set(commands.map((command) => command.group))]; + for (const group of groups) { + const inGroup = commands.filter((command) => command.group === group); + if (group === undefined) { + printer.line('## Commands'); + printer.blank(); + } else { + printer.line(`## ${group}`); + printer.blank(); + printer.line(`Addressed as \`${options.binName} ${groupSlug(group)} \`.`); + printer.blank(); + } + for (const command of inGroup) writeCommand(printer, command, options); + } + return ( + printer + .toString() + .replace(/\n{3,}/g, '\n\n') + .trimEnd() + '\n' + ); +} diff --git a/packages/client-generator/src/emitters/cli.ts b/packages/client-generator/src/emitters/cli.ts index c0c28e61fb..7f604a0de0 100644 --- a/packages/client-generator/src/emitters/cli.ts +++ b/packages/client-generator/src/emitters/cli.ts @@ -116,13 +116,22 @@ export type CliModuleOptions = { pagination?: PaginationConfig; }; -/** The whole `.cli.ts` file. */ -export function renderCliModule(model: ApiModel, options: CliModuleOptions): string { - const commands = commandData(model, { pagination: options.pagination }); - const schemes: CliAuthScheme[] = model.securitySchemes.map((scheme) => ({ +/** + * The auth schemes as the CLI sees them: every apiKey placement is one `apiKey` kind, + * since the credential is read from the same env variable either way. Exported so the + * docs generator names the same variables the runtime reads. + */ +export function cliAuthSchemes(model: ApiModel): CliAuthScheme[] { + return model.securitySchemes.map((scheme) => ({ key: scheme.key, kind: scheme.kind === 'bearer' || scheme.kind === 'basic' ? scheme.kind : 'apiKey', })); +} + +/** The whole `.cli.ts` file. */ +export function renderCliModule(model: ApiModel, options: CliModuleOptions): string { + const commands = commandData(model, { pagination: options.pagination }); + const schemes = cliAuthSchemes(model); const clientModule = `./${options.stem}.${options.importExt}`; const clientImports = ['client', 'configure', ...(options.zodSelected ? ['use'] : [])]; diff --git a/packages/client-generator/src/emitters/runtime-sources.ts b/packages/client-generator/src/emitters/runtime-sources.ts index 8f4f225ffb..2ec99ae497 100644 --- a/packages/client-generator/src/emitters/runtime-sources.ts +++ b/packages/client-generator/src/emitters/runtime-sources.ts @@ -25,7 +25,7 @@ export const RUNTIME_SOURCES = { 'paginate.ts': "import type { OperationArgs } from './create-client.js';\nimport type { PaginationSpec, QueryValue, RequestOptions } from './types.js';\n\n/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `params` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nexport function resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `params[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nexport async function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.params?.[spec.param];\n while (true) {\n const params = { ...args.params };\n if (cursor !== undefined) params[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, params }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `params[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.params?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call(\n { ...args, params: { ...args.params, [spec.param]: position } },\n init\n );\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nexport async function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\nexport type LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nexport function linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nexport async function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let params = args.params;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, params }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n params = { ...args.params, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nexport async function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n", 'cli.ts': - "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. */\n positionals: Array<{ name: string; description?: string }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n binName: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nfunction groupSlug(group: string): string {\n return group\n .trim()\n .replace(/[^A-Za-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .toLowerCase();\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nfunction envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist.\n const prefix = envPrefix(binName);\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ' --token Bearer token',\n ` --json Request body`,\n '',\n 'Environment:',\n ` ${prefix}_TOKEN, ${prefix}_USERNAME/${prefix}_PASSWORD, ${prefix}_API_KEY_`,\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: CliCommand[],\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(commands, wiring.binName, invocation.topic)) stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(invocation.command.schemas ?? {}, null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", + "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. */\n positionals: Array<{ name: string; description?: string }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n binName: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nexport function groupSlug(group: string): string {\n return group\n .trim()\n .replace(/[^A-Za-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .toLowerCase();\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nexport function envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist.\n const prefix = envPrefix(binName);\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ' --token Bearer token',\n ` --json Request body`,\n '',\n 'Environment:',\n ` ${prefix}_TOKEN, ${prefix}_USERNAME/${prefix}_PASSWORD, ${prefix}_API_KEY_`,\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: CliCommand[],\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(commands, wiring.binName, invocation.topic)) stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(invocation.command.schemas ?? {}, null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", } as const; /** Inline-embed variants: imports dropped, `export` stripped outside the kept surface. */ diff --git a/packages/client-generator/src/generators/__tests__/cli-docs.test.ts b/packages/client-generator/src/generators/__tests__/cli-docs.test.ts new file mode 100644 index 0000000000..b64e4dabbf --- /dev/null +++ b/packages/client-generator/src/generators/__tests__/cli-docs.test.ts @@ -0,0 +1,107 @@ +import { + modelWith, + namedSchema, + operation, + param, + response, +} from '../../emitters/__tests__/fixtures.js'; +import { cliDocsGenerator } from '../cli-docs/index.js'; + +const CAFE = modelWith( + [ + operation({ + name: 'listOrders', + method: 'get', + path: '/orders', + tags: ['Coffee Orders'], + summary: 'List orders\nacross every shop', + queryParams: [ + param('status', 'query', false, { + kind: 'enum', + scalar: 'string', + values: ['open', 'closed'], + }), + param('maxTotal', 'query', false, { kind: 'scalar', scalar: 'number' }), + ], + successResponses: [response({ schema: { kind: 'ref', name: 'Order' } })], + }), + operation({ + name: 'createOrder', + method: 'post', + path: '/orders', + tags: ['Coffee Orders'], + summary: 'Create an order | with a pipe', + requestBody: { + contentType: 'application/json', + required: true, + schema: { kind: 'ref', name: 'Order' }, + }, + successResponses: [response({ status: 201, schema: { kind: 'ref', name: 'Order' } })], + }), + operation({ name: 'ping', method: 'get', path: '/ping' }), + ], + { + title: 'Cafe API', + schemas: [namedSchema('Order', { kind: 'object', properties: [] })], + securitySchemes: [{ key: 'BearerAuth', kind: 'bearer' }], + } +); + +function render(options: Record = {}): string { + const files = cliDocsGenerator({ + model: CAFE, + outputPath: '/out/cafe.client.ts', + outputMode: 'single', + emit: {}, + selected: ['sdk', 'zod', 'cli', 'cli-docs'], + options, + }); + expect(files).toHaveLength(1); + expect(files[0].path).toBe('/out/cafe.client.cli.md'); + return files[0].content; +} + +describe('cliDocsGenerator', () => { + it('documents every command the CLI dispatches, addressed the way the CLI addresses it', () => { + const page = render(); + // Groups are the slugs the CLI accepts, with the original tag as the section title. + expect(page).toContain('## Coffee Orders'); + expect(page).toContain('### `coffee-orders listOrders`'); + expect(page).toContain('### `coffee-orders createOrder`'); + // An untagged operation is addressed without a group. + expect(page).toContain('### `ping`'); + expect(page).toContain('GET /orders'); + }); + + it('renders flags with type, requiredness, and choices', () => { + const page = render(); + expect(page).toContain('--status'); + expect(page).toContain('`open`, `closed`'); + // A number-typed query param is documented as a number, not a string. + expect(page).toMatch(/--max-total.*number/); + expect(page).toContain('--json'); + }); + + it('carries the global flags, the credential variables, and the exit-code contract', () => { + const page = render(); + expect(page).toContain('--page-all'); + // The env prefix comes from the bin name the CLI derives from the same stem. + expect(page).toContain('CAFE_CLIENT_TOKEN'); + expect(page).toContain('| 3 |'); + expect(page).toContain('validation error'); + }); + + it('keeps a description safe inside a table cell', () => { + const page = render(); + // A newline would break the row; a pipe would open a new column. + expect(page).toContain('List orders across every shop'); + expect(page).toContain('Create an order \\| with a pipe'); + }); + + it('honors its declared options', () => { + expect(render()).toContain('# Cafe API command-line reference'); + const custom = render({ title: 'Coffee CLI', frontmatter: true }); + expect(custom.startsWith('---\ntitle: Coffee CLI\n---\n')).toBe(true); + expect(custom).toContain('# Coffee CLI'); + }); +}); 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 906816ce01..f0987198ae 100644 --- a/packages/client-generator/src/generators/__tests__/generator-skills.test.ts +++ b/packages/client-generator/src/generators/__tests__/generator-skills.test.ts @@ -14,7 +14,16 @@ const generatorsDir = resolve(dirname(fileURLToPath(import.meta.url)), '..'); /** Language generators: one self-contained file, ejected as its own source. */ const LANGUAGE = ['python', 'go', 'php']; /** TypeScript generators: thin entries over shared emitters, ejected bundled with them. */ -const TYPESCRIPT = ['sdk', 'zod', 'mock', 'cli', 'swr', 'tanstack-query', 'transformers']; +const TYPESCRIPT = [ + 'sdk', + 'zod', + 'mock', + 'cli', + 'cli-docs', + 'swr', + 'tanstack-query', + 'transformers', +]; const EJECTABLE = [...LANGUAGE, ...TYPESCRIPT]; describe.each(EJECTABLE)('%s generator skill', (name) => { diff --git a/packages/client-generator/src/generators/cli-docs/AGENTS.md b/packages/client-generator/src/generators/cli-docs/AGENTS.md new file mode 100644 index 0000000000..9b2afdc797 --- /dev/null +++ b/packages/client-generator/src/generators/cli-docs/AGENTS.md @@ -0,0 +1,52 @@ +# The `cli-docs` generator — its skill + +This file is the generator's DESIGN and governs our own changes: **to change the +generator, edit this skill first, then make the code match it.** + +## What it emits + +`.cli.md` — the Markdown reference for the generated CLI: the usage line, the +global flags, the credential environment variables, the exit-code table, and one section +per command with its positionals and flags (type, required, choices, description). + +## Design decisions that must hold + +- **One source of truth**: the page renders from `commandData(model, emit)` — the same + table `runCli` dispatches on — and from `groupSlug`/`envPrefix`, the same functions the + runtime addresses groups and reads credentials with. Documentation that derives from a + second model drifts from the tool the first time either side changes, so it never does + that. A new CLI capability shows up here only when it is in the command table. +- **Requires the `cli` generator** it documents: selecting `cli-docs` pulls in `cli` (and + through it `sdk` and `zod`), so `--generator cli-docs` is a complete, consistent set. +- **The renderer IS the template.** Publishers who need another structure eject this + generator rather than learning a template syntax — one customization mechanism, no + template engine, no new dependency. Light customization stays in declared options. +- **Declared options**: `title` (page heading, default ` CLI`) and + `frontmatter` (emit YAML front matter with the title, default `false`). Both are + validated by the pipeline before `run`, so the renderer reads them directly. +- **Markdown that survives a linter**: ATX headings, a blank line around every block, no + hard tabs, and one sentence per line in prose — generated docs land in repos that lint + Markdown in CI. +- **Escapes what descriptions contain**: a summary or description is arbitrary text, so + pipes are escaped inside table cells and newlines collapse to spaces. + +## Emitters that implement it + +`emitters/cli-docs.ts` (the page renderer), over `emitters/cli.ts`'s `commandData` and +the runtime's `groupSlug`/`envPrefix`. + +## Ejecting it + +`redocly eject-generator cli-docs` ships this generator BUNDLED with the emitter it uses — +one small `.mjs` you own, importing `@redocly/client-generator` and +`@redocly/openapi-core`. Change the sections, the wording, or the table columns, and +regenerate: this is the answer to "can the documentation templates be ejected too". + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Change the emitter modules named above (the entry is plumbing — it rarely moves). +3. Verify: `npm run compile`, the emitter unit suites + (`VITEST_SUITE=unit npx vitest run packages/client-generator/src/emitters`), the cli + e2e suites, and the large-description bars + (`tests/e2e/generate-client/large-descriptions.test.ts`). diff --git a/packages/client-generator/src/generators/cli-docs/index.ts b/packages/client-generator/src/generators/cli-docs/index.ts new file mode 100644 index 0000000000..480506a47f --- /dev/null +++ b/packages/client-generator/src/generators/cli-docs/index.ts @@ -0,0 +1,49 @@ +import { join } from 'node:path'; + +import { renderCliDocs } from '../../emitters/cli-docs.js'; +import { cliAuthSchemes, commandData } from '../../emitters/cli.js'; +import { anchor } from '../anchor.js'; +import type { Generator, GeneratorOptionsSchema } from '../types.js'; + +/** + * The cli-docs generator: `.cli.md`, the Markdown reference for the generated CLI — + * usage, global flags, credential variables, exit codes, and every command with its + * positionals and flags. It renders from the same command table the CLI dispatches on, so + * the page cannot drift from the tool it documents. + */ +export const cliDocsOptions: GeneratorOptionsSchema = { + type: 'object', + properties: { + title: { + type: 'string', + description: 'Page heading. Defaults to " command-line reference".', + }, + frontmatter: { + type: 'boolean', + default: false, + description: 'Emit YAML front matter carrying the title, for docs sites that expect it.', + }, + }, + additionalProperties: false, +}; + +/** The stem as a command name — the same fold the cli generator applies. */ +function commandName(stem: string): string { + return ( + stem + .replace(/[^A-Za-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .toLowerCase() || 'client' + ); +} + +export const cliDocsGenerator: Generator = ({ model, outputPath, emit, options }) => { + const { dir, stem } = anchor(outputPath); + const content = renderCliDocs(commandData(model, { pagination: emit.pagination }), { + title: (options?.title as string | undefined) ?? `${model.title} command-line reference`, + frontmatter: options?.frontmatter === true, + binName: emit.binName ?? commandName(stem), + schemes: cliAuthSchemes(model), + }); + return [{ path: join(dir, `${stem}.cli.md`), content }]; +}; diff --git a/packages/client-generator/src/generators/index.ts b/packages/client-generator/src/generators/index.ts index f3287b7cee..2f380cf297 100644 --- a/packages/client-generator/src/generators/index.ts +++ b/packages/client-generator/src/generators/index.ts @@ -1,4 +1,5 @@ import type { EmitOptions } from '../emitters/emit-options.js'; +import { cliDocsGenerator } from './cli-docs/index.js'; import { cliGenerator, cliSample } from './cli/index.js'; import { goGenerator, goSample } from './go/index.js'; import { BUILTIN_META, validateSelection, type BuiltinMeta } from './meta.js'; @@ -35,6 +36,7 @@ const RUNS: Record> = swr: { run: swrGenerator }, mock: { run: mockGenerator }, cli: { run: cliGenerator, sample: cliSample }, + 'cli-docs': { run: cliDocsGenerator }, python: { run: pythonGenerator, sample: pythonSample }, go: { run: goGenerator, sample: goSample }, php: { run: phpGenerator, sample: phpSample }, diff --git a/packages/client-generator/src/generators/meta.ts b/packages/client-generator/src/generators/meta.ts index f0f994fc91..2daccdc846 100644 --- a/packages/client-generator/src/generators/meta.ts +++ b/packages/client-generator/src/generators/meta.ts @@ -78,6 +78,22 @@ export const BUILTIN_META: Record = { load: () => import('./cli/index.js').then((m) => ({ run: m.cliGenerator, sample: m.cliSample })), }, + // cli-docs renders the Markdown reference for the CLI from the same command table the + // CLI dispatches on, so it requires the generator it documents. + 'cli-docs': { + requires: ['cli'], + errorModes: ['throw'], + notApplicable: { + outputMode: 'it emits one Markdown page', + importExt: 'a Markdown page has no imports', + runtime: 'a Markdown page embeds no runtime', + }, + load: () => + import('./cli-docs/index.js').then((m) => ({ + run: m.cliDocsGenerator, + options: m.cliDocsOptions, + })), + }, // python emits a standalone full Python SDK (httpx) — no TypeScript involved, // so a python-only selection never loads the `typescript` package. python: { diff --git a/packages/client-generator/src/generators/types.ts b/packages/client-generator/src/generators/types.ts index 8aaf5fd4d3..14c13ed1dd 100644 --- a/packages/client-generator/src/generators/types.ts +++ b/packages/client-generator/src/generators/types.ts @@ -28,6 +28,7 @@ export type GeneratorName = | 'transformers' | 'mock' | 'cli' + | 'cli-docs' | 'python' | 'go' | 'php'; diff --git a/packages/client-generator/src/runtime/cli.ts b/packages/client-generator/src/runtime/cli.ts index 7a5ca33a18..dad95978b3 100644 --- a/packages/client-generator/src/runtime/cli.ts +++ b/packages/client-generator/src/runtime/cli.ts @@ -91,7 +91,7 @@ const GLOBAL_FLAGS: Record * multi-word tag"), which only resolves if the user quotes it. Commands are addressed by * this slug; help still shows the original tag. */ -function groupSlug(group: string): string { +export function groupSlug(group: string): string { return group .trim() .replace(/[^A-Za-z0-9]+/g, '-') @@ -238,7 +238,7 @@ export function parseInvocation(commands: CliCommand[], argv: string[]): CliInvo } /** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */ -function envPrefix(binName: string): string { +export function envPrefix(binName: string): string { return binName .replace(/[^A-Za-z0-9]+/g, '_') .replace(/([a-z0-9])([A-Z])/g, '$1_$2') diff --git a/tests/e2e/generate-client/cli-docs.test.ts b/tests/e2e/generate-client/cli-docs.test.ts new file mode 100644 index 0000000000..64c4e2f1e6 --- /dev/null +++ b/tests/e2e/generate-client/cli-docs.test.ts @@ -0,0 +1,95 @@ +// The cli-docs generator end-to-end: the page it writes must describe the CLI that ships +// beside it, so the bar is a comparison against the generated CLI's own `--help`. +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { generate, repoRoot, tsxBin } from './helpers.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixture = join(__dirname, 'fixtures/cli.yaml'); + +let dir: string; +let page: string; + +// Generating and spawning the CLI through tsx can approach the 5s default under load. +vi.setConfig({ testTimeout: 120_000 }); + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'cli-docs-')); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ type: 'module' }), 'utf-8'); + // The CLI validates with zod, and this temp dir is outside the repo: borrow its modules. + symlinkSync(join(repoRoot, 'node_modules'), join(dir, 'node_modules'), 'dir'); + // `cli-docs` pulls in the CLI it documents, so this one flag is the whole selection. + generate(fixture, join(dir, 'cafe.client.ts'), ['--generator', 'cli-docs']); + page = readFileSync(join(dir, 'cafe.client.cli.md'), 'utf-8'); +}); + +afterAll(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe('generate-client cli-docs generator (end-to-end)', () => { + it('emits the page beside the CLI it documents, pulling the CLI in on its own', () => { + expect(existsSync(join(dir, 'cafe.client.cli.ts'))).toBe(true); + expect(existsSync(join(dir, 'cafe.client.cli.md'))).toBe(true); + }); + + it('documents every command the CLI dispatches, addressed exactly as --help shows it', () => { + const help = (args: string[]): string => { + const result = spawnSync(tsxBin, [join(dir, 'cafe.client.cli.ts'), ...args], { + cwd: dir, + encoding: 'utf-8', + }); + expect(result.status, result.stderr).toBe(0); + return result.stdout; + }; + /** The `Commands:` block of a help screen, one entry per line, summaries stripped. */ + const entries = (text: string): string[] => + text + .slice(text.indexOf('Commands:') + 'Commands:'.length, text.indexOf('Global flags:')) + .split('\n') + .map((line) => line.trim()) + .filter((line) => line !== '') + .map((line) => line.split(/\s{2,}/)[0]); + + // Top-level help lists groups (`orders `) and any ungrouped command; each + // group's own help lists its commands. Walk both levels, so nothing is assumed. + const addresses: string[] = []; + for (const entry of entries(help(['--help']))) { + if (entry.endsWith(' ')) { + addresses.push(...entries(help([entry.replace(' ', ''), '--help']))); + } else { + addresses.push(entry); + } + } + + expect(addresses.length).toBeGreaterThan(3); + for (const address of addresses) { + expect(page, `${address} is missing from the reference page`).toContain(`### \`${address}\``); + } + }); + + it('carries the credential variables and exit codes the CLI actually uses', () => { + // The env prefix is derived from the same stem the CLI derives it from. + expect(page).toContain('CAFE_CLIENT_TOKEN'); + expect(page).toContain('| 3 | validation error |'); + }); + + it('is well-formed Markdown: one H1, balanced fences, no tabs or trailing spaces', () => { + const lines = page.split('\n'); + expect(lines.filter((line) => line.startsWith('# '))).toHaveLength(1); + expect(lines.filter((line) => line.startsWith('```')).length % 2).toBe(0); + expect(page).not.toContain('\t'); + expect(lines.filter((line) => /\s$/.test(line))).toEqual([]); + // A heading and a table never sit on adjacent lines — markdownlint (MD022/MD058) and + // most renderers need the blank line. + for (let index = 1; index < lines.length; index++) { + if (lines[index].startsWith('|') && lines[index - 1] !== '') { + expect(lines[index - 1].startsWith('|')).toBe(true); + } + } + }); +}); From 4a529311bbad12eaa7448c8892e037fb9be5ef65 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Fri, 7 Aug 2026 00:02:45 +0300 Subject: [PATCH 116/211] feat(cli): document commands whose body the CLI cannot build, and skip response validation on a dry run --- .changeset/cli-docs-generator.md | 2 ++ .changeset/cli-dry-run-response-validation.md | 5 +++++ .../src/emitters/__tests__/cli.test.ts | 4 +++- .../client-generator/src/emitters/cli-docs.ts | 3 +++ packages/client-generator/src/emitters/cli.ts | 12 ++++++++++- .../src/emitters/runtime-sources.ts | 4 ++-- .../src/generators/__tests__/cli-docs.test.ts | 20 +++++++++++++++++++ .../src/generators/__tests__/cli.test.ts | 6 +++++- packages/client-generator/src/runtime/cli.ts | 12 +++++++++++ .../generate-client/examples/cli/README.md | 11 ++++++++-- .../generate-client/examples/cli/redocly.yaml | 4 ++++ 11 files changed, 76 insertions(+), 7 deletions(-) create mode 100644 .changeset/cli-dry-run-response-validation.md diff --git a/.changeset/cli-docs-generator.md b/.changeset/cli-docs-generator.md index a808e92ac0..e4fc4475aa 100644 --- a/.changeset/cli-docs-generator.md +++ b/.changeset/cli-docs-generator.md @@ -4,3 +4,5 @@ --- Added a `cli-docs` generator that writes the Markdown reference for the generated CLI — every command, flag, credential variable, and exit code — rendered from the same command table the CLI dispatches on. + +An operation whose request body the CLI cannot build (multipart, url-encoded, binary) now says so in both its `--help` and its reference entry, instead of appearing runnable. diff --git a/.changeset/cli-dry-run-response-validation.md b/.changeset/cli-dry-run-response-validation.md new file mode 100644 index 0000000000..def79a798e --- /dev/null +++ b/.changeset/cli-dry-run-response-validation.md @@ -0,0 +1,5 @@ +--- +'@redocly/client-generator': patch +--- + +Fixed the generated CLI reporting response-validation drift under `--dry-run`, where the only response is the dry-run stub; request validation still runs. diff --git a/packages/client-generator/src/emitters/__tests__/cli.test.ts b/packages/client-generator/src/emitters/__tests__/cli.test.ts index 1bc6644f00..20dff3e78e 100644 --- a/packages/client-generator/src/emitters/__tests__/cli.test.ts +++ b/packages/client-generator/src/emitters/__tests__/cli.test.ts @@ -240,6 +240,8 @@ describe('renderCliModule', () => { expect(out).toContain('import { runCli, type CliCommand } from "@redocly/client-generator";'); expect(out).not.toContain('function parseInvocation'); expect(out).toContain('import { zodValidation } from "./client.zod.js";'); - expect(out).toContain('use(zodValidation());'); + expect(out).toContain( + 'use(zodValidation(process.argv.includes("--dry-run") ? { response: false } : {}));' + ); }); }); diff --git a/packages/client-generator/src/emitters/cli-docs.ts b/packages/client-generator/src/emitters/cli-docs.ts index 4d3109234d..53fdb30391 100644 --- a/packages/client-generator/src/emitters/cli-docs.ts +++ b/packages/client-generator/src/emitters/cli-docs.ts @@ -86,6 +86,9 @@ function writeCommand(printer: Printer, command: CliCommand, options: CliDocsOpt command.body === undefined ? '' : `Takes a JSON body${command.body.required ? ' (required)' : ''}: \`--json ''\`, \`--json @file.json\`, or \`--json @-\` for stdin.`, + command.unsupportedBody === undefined + ? '' + : `Takes a \`${command.unsupportedBody}\` body, which the CLI cannot build — call this operation through the generated client instead.`, command.paginated === true ? 'Paginated: `--page-all` follows every page, printing one JSON page per line.' : '', diff --git a/packages/client-generator/src/emitters/cli.ts b/packages/client-generator/src/emitters/cli.ts index 7f604a0de0..0134d777bb 100644 --- a/packages/client-generator/src/emitters/cli.ts +++ b/packages/client-generator/src/emitters/cli.ts @@ -81,6 +81,9 @@ export function commandData( })), flags: op.queryParams.map(flagFor), ...(jsonBody ? { body: { required: jsonBody.required } } : {}), + ...(jsonBody === undefined && op.requestBody !== undefined + ? { unsupportedBody: op.requestBody.contentType } + : {}), ...(resolveOperationPagination(op, model, emit.pagination).spec !== undefined ? { paginated: true } : {}), @@ -152,7 +155,14 @@ export function renderCliModule(model: ApiModel, options: CliModuleOptions): str ? ['// ─── Embedded cli engine (@redocly/client-generator) ───\n' + embedCliRuntime()] : []), `const COMMANDS: CliCommand[] = ${codeJson(commands, 2)};`, - ...(options.zodSelected ? ['use(zodValidation());'] : []), + ...(options.zodSelected + ? [ + // A dry run never sends the request, so its "response" is the stub the dry-run + // fetch returns — validating that reports drift that does not exist. Request + // validation still runs, which is what makes `--dry-run` a useful preflight. + `use(zodValidation(process.argv.includes("--dry-run") ? { response: false } : {}));`, + ] + : []), `process.exit( await runCli(COMMANDS, { binName: ${codeJson(options.binName)}, diff --git a/packages/client-generator/src/emitters/runtime-sources.ts b/packages/client-generator/src/emitters/runtime-sources.ts index 2ec99ae497..8fe5db4d3c 100644 --- a/packages/client-generator/src/emitters/runtime-sources.ts +++ b/packages/client-generator/src/emitters/runtime-sources.ts @@ -25,7 +25,7 @@ export const RUNTIME_SOURCES = { 'paginate.ts': "import type { OperationArgs } from './create-client.js';\nimport type { PaginationSpec, QueryValue, RequestOptions } from './types.js';\n\n/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `params` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nexport function resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `params[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nexport async function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.params?.[spec.param];\n while (true) {\n const params = { ...args.params };\n if (cursor !== undefined) params[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, params }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `params[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.params?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call(\n { ...args, params: { ...args.params, [spec.param]: position } },\n init\n );\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nexport async function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\nexport type LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nexport function linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nexport async function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let params = args.params;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, params }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n params = { ...args.params, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nexport async function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n", 'cli.ts': - "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. */\n positionals: Array<{ name: string; description?: string }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n binName: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nexport function groupSlug(group: string): string {\n return group\n .trim()\n .replace(/[^A-Za-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .toLowerCase();\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nexport function envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist.\n const prefix = envPrefix(binName);\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ' --token Bearer token',\n ` --json Request body`,\n '',\n 'Environment:',\n ` ${prefix}_TOKEN, ${prefix}_USERNAME/${prefix}_PASSWORD, ${prefix}_API_KEY_`,\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: CliCommand[],\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(commands, wiring.binName, invocation.topic)) stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(invocation.command.schemas ?? {}, null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", + "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. */\n positionals: Array<{ name: string; description?: string }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n binName: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nexport function groupSlug(group: string): string {\n return group\n .trim()\n .replace(/[^A-Za-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .toLowerCase();\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nexport function envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist.\n const prefix = envPrefix(binName);\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ' --token Bearer token',\n ` --json Request body`,\n '',\n 'Environment:',\n ` ${prefix}_TOKEN, ${prefix}_USERNAME/${prefix}_PASSWORD, ${prefix}_API_KEY_`,\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: CliCommand[],\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(commands, wiring.binName, invocation.topic)) stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(invocation.command.schemas ?? {}, null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", } as const; /** Inline-embed variants: imports dropped, `export` stripped outside the kept surface. */ @@ -55,7 +55,7 @@ export const RUNTIME_SOURCES_STRIPPED = { 'paginate.ts': "/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `params` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nfunction resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `params[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nasync function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.params?.[spec.param];\n while (true) {\n const params = { ...args.params };\n if (cursor !== undefined) params[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, params }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `params[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.params?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call(\n { ...args, params: { ...args.params, [spec.param]: position } },\n init\n );\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nasync function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\ntype LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nfunction linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nasync function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let params = args.params;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, params }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n params = { ...args.params, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nasync function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}", 'cli.ts': - "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\ntype CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\ntype CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. */\n positionals: Array<{ name: string; description?: string }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\ntype CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\ntype CliWiring = {\n binName: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\ntype CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nfunction groupSlug(group: string): string {\n return group\n .trim()\n .replace(/[^A-Za-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .toLowerCase();\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nfunction parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nfunction envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist.\n const prefix = envPrefix(binName);\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ' --token Bearer token',\n ` --json Request body`,\n '',\n 'Environment:',\n ` ${prefix}_TOKEN, ${prefix}_USERNAME/${prefix}_PASSWORD, ${prefix}_API_KEY_`,\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nasync function runCli(\n commands: CliCommand[],\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(commands, wiring.binName, invocation.topic)) stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(invocation.command.schemas ?? {}, null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}", + "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\ntype CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\ntype CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. */\n positionals: Array<{ name: string; description?: string }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\ntype CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\ntype CliWiring = {\n binName: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\ntype CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nfunction groupSlug(group: string): string {\n return group\n .trim()\n .replace(/[^A-Za-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .toLowerCase();\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nfunction parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nfunction envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist.\n const prefix = envPrefix(binName);\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ' --token Bearer token',\n ` --json Request body`,\n '',\n 'Environment:',\n ` ${prefix}_TOKEN, ${prefix}_USERNAME/${prefix}_PASSWORD, ${prefix}_API_KEY_`,\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nasync function runCli(\n commands: CliCommand[],\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(commands, wiring.binName, invocation.topic)) stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(invocation.command.schemas ?? {}, null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}", } as const; export type RuntimeModuleName = keyof typeof RUNTIME_SOURCES; diff --git a/packages/client-generator/src/generators/__tests__/cli-docs.test.ts b/packages/client-generator/src/generators/__tests__/cli-docs.test.ts index b64e4dabbf..c312c490fc 100644 --- a/packages/client-generator/src/generators/__tests__/cli-docs.test.ts +++ b/packages/client-generator/src/generators/__tests__/cli-docs.test.ts @@ -38,6 +38,17 @@ const CAFE = modelWith( }, successResponses: [response({ status: 201, schema: { kind: 'ref', name: 'Order' } })], }), + operation({ + name: 'uploadPhoto', + method: 'post', + path: '/menu/{id}/photo', + tags: ['Coffee Orders'], + requestBody: { + contentType: 'multipart/form-data', + required: true, + schema: { kind: 'unknown' }, + }, + }), operation({ name: 'ping', method: 'get', path: '/ping' }), ], { @@ -91,6 +102,15 @@ describe('cliDocsGenerator', () => { expect(page).toContain('validation error'); }); + it('says when a body is one the CLI cannot build, instead of implying the command runs', () => { + const page = render(); + expect(page).toContain('### `coffee-orders uploadPhoto`'); + expect(page).toContain('`multipart/form-data` body, which the CLI cannot build'); + // And it does not advertise --json for that command. + const section = page.slice(page.indexOf('### `coffee-orders uploadPhoto`')); + expect(section.slice(0, section.indexOf('###', 3))).not.toContain('--json'); + }); + it('keeps a description safe inside a table cell', () => { const page = render(); // A newline would break the row; a pipe would open a new column. diff --git a/packages/client-generator/src/generators/__tests__/cli.test.ts b/packages/client-generator/src/generators/__tests__/cli.test.ts index f0c4b3e3d3..10b22ae0db 100644 --- a/packages/client-generator/src/generators/__tests__/cli.test.ts +++ b/packages/client-generator/src/generators/__tests__/cli.test.ts @@ -64,7 +64,11 @@ describe('cliGenerator', () => { emit: {}, selected: ['sdk', 'zod', 'cli'], }); - expect(withZod[0].content).toContain('use(zodValidation());'); + // Request validation always; response validation off for a dry run, whose response is + // the dry-run stub rather than the server's. + expect(withZod[0].content).toContain( + 'use(zodValidation(process.argv.includes("--dry-run") ? { response: false } : {}));' + ); }); it('declares its prerequisites and rejects result mode', () => { diff --git a/packages/client-generator/src/runtime/cli.ts b/packages/client-generator/src/runtime/cli.ts index dad95978b3..628f189ed4 100644 --- a/packages/client-generator/src/runtime/cli.ts +++ b/packages/client-generator/src/runtime/cli.ts @@ -30,6 +30,12 @@ export type CliCommand = { flags: CliFlag[]; /** Present when the operation takes a JSON request body. */ body?: { required: 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; sse?: boolean; blob?: boolean; @@ -287,6 +293,12 @@ function renderHelp( ].join(' '); const lines = [`Usage: ${usage}`]; if (command.summary) lines.push('', command.summary); + if (command.unsupportedBody !== undefined) { + lines.push( + '', + `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.` + ); + } if (command.flags.length > 0) { lines.push('', 'Flags:'); for (const flag of command.flags) { diff --git a/tests/e2e/generate-client/examples/cli/README.md b/tests/e2e/generate-client/examples/cli/README.md index 3957918001..976c21e3fb 100644 --- a/tests/e2e/generate-client/examples/cli/README.md +++ b/tests/e2e/generate-client/examples/cli/README.md @@ -4,14 +4,17 @@ The `cli` generator emits `src/api/client.cli.ts` — a bin-ready, zero-dependen Path params are positional, query params become typed `--kebab-name` flags, and JSON bodies arrive via `--json ''`, `--json @file.json`, or `--json @-` (stdin). With `zod` co-selected (as here), requests are validated before they are sent — an invalid body exits with code 3 and never reaches the network. +Commands are grouped by tag and addressed by the tag's shell-typable slug — `Products` is typed `products` — and a unique operationId also works on its own. + Generate the client, then drive the API from the shell: ```sh npm run generate npx tsx src/api/client.cli.ts --help -npx tsx src/api/client.cli.ts Products listMenuItems --limit 3 -npx tsx src/api/client.cli.ts Orders createOrder --json @order.json --dry-run +npx tsx src/api/client.cli.ts products listMenuItems --limit 3 +npx tsx src/api/client.cli.ts listMenuItems --limit 3 # the group is optional when unambiguous +npx tsx src/api/client.cli.ts orders createOrder --json @order.json --dry-run npx tsx src/api/client.cli.ts schema createOrder ``` @@ -19,3 +22,7 @@ npx tsx src/api/client.cli.ts schema createOrder Credentials come from environment variables derived from the file stem: `CLIENT_TOKEN` for bearer auth here, or pass `--token`. Exit codes are a documented contract (0 ok, 1 API error, 2 auth, 3 validation, 4 usage), and errors print one JSON object to stderr so stdout stays clean for piping. To ship a real bin, compile with `tsc` and point `package.json`'s `bin` at the compiled file. + +The `cli-docs` generator (also selected here) writes `src/api/client.cli.md` alongside it: the Markdown reference for this CLI — usage, global flags, credential variables, exit codes, and every command with its arguments and flags. +It renders from the same command table the CLI dispatches on, so the page cannot drift from the tool; regenerate and the docs follow. +`client.options.cli-docs` sets the page title here, and `redocly eject-generator cli-docs` hands over the renderer itself if you want a different structure — the renderer is the template. diff --git a/tests/e2e/generate-client/examples/cli/redocly.yaml b/tests/e2e/generate-client/examples/cli/redocly.yaml index 2d5e6704b6..f18d6ee8db 100644 --- a/tests/e2e/generate-client/examples/cli/redocly.yaml +++ b/tests/e2e/generate-client/examples/cli/redocly.yaml @@ -8,3 +8,7 @@ apis: - sdk - zod - cli + - cli-docs + options: + cli-docs: + title: Cafe CLI From a8fed7215836aeaedbfeb3d5e4588d240baf86ac Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Fri, 7 Aug 2026 08:43:12 +0300 Subject: [PATCH 117/211] Potential fix for pull request finding 'CodeQL / Incomplete string escaping or encoding' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- packages/client-generator/src/emitters/cli-docs.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/client-generator/src/emitters/cli-docs.ts b/packages/client-generator/src/emitters/cli-docs.ts index 53fdb30391..3ff15b13fc 100644 --- a/packages/client-generator/src/emitters/cli-docs.ts +++ b/packages/client-generator/src/emitters/cli-docs.ts @@ -17,9 +17,13 @@ export type CliDocsOptions = { schemes: Array<{ key: string; kind: 'bearer' | 'basic' | 'apiKey' }>; }; -/** Table-cell-safe text: one line, and pipes escaped so they don't open a column. */ +/** Table-cell-safe text: one line, and pipes/backslashes escaped so they don't alter columns/escaping. */ function cell(text: string | undefined): string { - return (text ?? '').replace(/\s+/g, ' ').trim().replace(/\|/g, '\\|'); + return (text ?? '') + .replace(/\s+/g, ' ') + .trim() + .replace(/\\/g, '\\\\') + .replace(/\|/g, '\\|'); } /** How a command is typed at the prompt: ` `, or just ``. */ From 1cee0396d667c6989e41dcdb33bd317d543d3edc Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Fri, 7 Aug 2026 08:43:29 +0300 Subject: [PATCH 118/211] Potential fix for pull request finding 'CodeQL / Improper code sanitization' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../src/emitters/mock-value.ts | 3 ++- .../src/emitters/ts-literal.ts | 24 +++++++++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/client-generator/src/emitters/mock-value.ts b/packages/client-generator/src/emitters/mock-value.ts index b06606741e..b8412c17c2 100644 --- a/packages/client-generator/src/emitters/mock-value.ts +++ b/packages/client-generator/src/emitters/mock-value.ts @@ -3,6 +3,7 @@ // where indentation is threaded. Deliberately tiny. import { safeIdent } from './identifier.js'; +import { sanitizeCodeString } from './ts-literal.js'; export type MockEntry = { key: string; value: MockValue } | { spread: string }; @@ -50,7 +51,7 @@ export function renderMockValue(value: MockValue, indent: string): string { const lines = value.entries.map((entry, index) => { const comma = index === value.entries.length - 1 ? '' : ','; if ('spread' in entry) return `${inner}...${entry.spread}${comma}`; - const key = safeIdent(entry.key) === entry.key ? entry.key : JSON.stringify(entry.key); + const key = safeIdent(entry.key) === entry.key ? entry.key : sanitizeCodeString(entry.key); return `${inner}${key}: ${renderMockValue(entry.value, inner)}${comma}`; }); return `{\n${lines.join('\n')}\n${indent}}`; diff --git a/packages/client-generator/src/emitters/ts-literal.ts b/packages/client-generator/src/emitters/ts-literal.ts index f41679b393..5978f087af 100644 --- a/packages/client-generator/src/emitters/ts-literal.ts +++ b/packages/client-generator/src/emitters/ts-literal.ts @@ -4,9 +4,29 @@ import { isIdentifier } from './identifier.js'; +const UNSAFE_STRING_CHARS = /[<>\/\\\b\f\n\r\t\0\u2028\u2029]/g; +const UNSAFE_STRING_CHAR_MAP: Record = { + '<': '\\u003C', + '>': '\\u003E', + '/': '\\u002F', + '\\': '\\\\', + '\b': '\\b', + '\f': '\\f', + '\n': '\\n', + '\r': '\\r', + '\t': '\\t', + '\0': '\\0', + '\u2028': '\\u2028', + '\u2029': '\\u2029', +}; + +export function sanitizeCodeString(value: string): string { + return JSON.stringify(value).replace(UNSAFE_STRING_CHARS, (char) => UNSAFE_STRING_CHAR_MAP[char] ?? char); +} + /** A JSON-ish value as TypeScript source text. */ export function codeLiteral(value: unknown): string { - if (typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'string') return sanitizeCodeString(value); if (typeof value === 'boolean' || value === null) return String(value); if (typeof value === 'number') return String(value); if (Array.isArray(value)) { @@ -14,7 +34,7 @@ export function codeLiteral(value: unknown): string { } const entries = Object.entries(value as Record).map( ([key, entryValue]) => - `${isIdentifier(key) ? key : JSON.stringify(key)}: ${codeLiteral(entryValue)}` + `${isIdentifier(key) ? key : sanitizeCodeString(key)}: ${codeLiteral(entryValue)}` ); return entries.length === 0 ? '{}' : `{ ${entries.join(', ')} }`; } From d0d6f457c8f969cfa4b83d93a53fddbcd6fbeb07 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Fri, 7 Aug 2026 08:33:36 +0300 Subject: [PATCH 119/211] fix(go): collapse consecutive blank doc-comment lines so output stays gofmt-clean --- .changeset/go-doc-comment-blank-lines.md | 5 ++++ docs/@v2/guides/use-generated-client.md | 5 ++++ packages/cli/src/index.ts | 2 +- .../eject-assets/skills/go-generator/SKILL.md | 4 +++ .../src/generators/__tests__/go.test.ts | 18 +++++++++++++ .../src/generators/go/AGENTS.md | 4 +++ .../src/generators/go/index.ts | 27 ++++++++++++++++--- .../generator-contract.test.ts | 20 +++++++++++++- 8 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 .changeset/go-doc-comment-blank-lines.md diff --git a/.changeset/go-doc-comment-blank-lines.md b/.changeset/go-doc-comment-blank-lines.md new file mode 100644 index 0000000000..34fd61585b --- /dev/null +++ b/.changeset/go-doc-comment-blank-lines.md @@ -0,0 +1,5 @@ +--- +'@redocly/client-generator': patch +--- + +Fixed the Go SDK emitting two blank comment lines where a description has consecutive blank lines, which left the output not gofmt-clean. diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index c603e1f2f4..e0139eb235 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -45,6 +45,11 @@ npx tsx src/client.cli.ts schema createOrder # request/response sche `--help` lists the commands, and for tagged APIs those are grouped: run ` --help` for one command's flags. An operationId also works on its own (` listOrders`) when it is unambiguous, so you don't have to know its group. + +Group and command names are cased differently, deliberately. +A group comes from an OpenAPI tag, which is prose — `Coffee Orders` is not typable without quoting — so it is slugged to `coffee-orders`. +A command name is the operationId, which is already an identifier, so it is used verbatim: `listOrders`, not `list-orders`. +That keeps one name for the operation across everything you generate — the CLI command, the TypeScript function, the Python method — so `listOrders` is searchable in your API description, your SDK, and your shell history alike. Every global flag appears under `Global flags:` in the top-level help — `--server-url`, `--format json|ndjson`, `--dry-run`, `--page-all`, `--output`, `--token`, `--json` — together with the environment variables the CLI reads. Credentials come from environment variables derived from the file stem (constant-cased): bearer → `_TOKEN` (or `--token`), basic → `_USERNAME`/`_PASSWORD`, apiKey → `_API_KEY_`. diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 59dbcc904a..ed3ad7ed4b 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -955,7 +955,7 @@ yargs(hideBin(process.argv)) }, generator: { describe: - 'Generator to run; repeat the flag to run several (default: sdk). A built-in generator name, or a path/package specifier for a custom one. The built-in list lives in the "Use the generated client" guide, so it stays in one place. Example: --generator sdk --generator zod', + 'Generator to run; repeat the flag to run several (default: sdk). Built-in: sdk, zod, tanstack-query, tanstack-query-vue, tanstack-query-svelte, tanstack-query-solid, swr, mock, transformers, cli, cli-docs, python, go, php — or a path/package specifier for a custom generator. What each one emits is in the "Use the generated client" guide. Example: --generator sdk --generator zod', type: 'string', array: true, requiresArg: true, 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 718c19c408..b9aa99a043 100644 --- a/packages/client-generator/eject-assets/skills/go-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/go-generator/SKILL.md @@ -24,6 +24,10 @@ runtime. Go ≥ 1.21, standard library only — zero dependencies. Go's own rule (lowercase letters, digits, `_`, no leading digit, not a keyword) and an invalid one fails generation: silently rewriting a publisher's package name would be worse than saying no. +- **Doc comments are gofmt's shape**, not the description's: a blank line prints as `//` + (never `// `, which gofmt strips), and CONSECUTIVE blank lines collapse to one — gofmt + rewrites `//\n//` to a single `//`, so emitting both means our output is not + gofmt-clean. Descriptions with a double blank line are common in real specs. - **Naming:** exported PascalCase via `identifierFor` + an `N` prefix for digit-leading names (`3ds` → `N3ds` — an `_`-prefixed field is unexported and invisible to `encoding/json`); `+1`/`-1` become `Plus1`/`Minus1`. diff --git a/packages/client-generator/src/generators/__tests__/go.test.ts b/packages/client-generator/src/generators/__tests__/go.test.ts index 68637a4b22..2dc7cc59d1 100644 --- a/packages/client-generator/src/generators/__tests__/go.test.ts +++ b/packages/client-generator/src/generators/__tests__/go.test.ts @@ -414,6 +414,24 @@ describe('goGenerator parity features', () => { expectGoCompiles(out); }); + it('collapses consecutive blank lines in a doc comment, as gofmt would', () => { + const out = renderGoModels( + model({ + Documented: { + kind: 'object', + description: 'First paragraph.\n\nSecond paragraph.\n\n\nThird after two blanks.', + properties: [{ name: 'id', schema: STRING, required: true }], + }, + }) + ); + expect(out).toContain('// Documented — First paragraph.'); + expect(out).toContain('// Third after two blanks.'); + // Two empty comment lines in a row is exactly what gofmt rewrites. + expect(out).not.toContain('//\n//\n'); + expectGofmtClean(out); + expectGoCompiles(out); + }); + it('emits gofmt-clean output — aligned struct fields and const blocks', () => { const out = generateGo(); // The alignment gofmt would apply, applied by us. diff --git a/packages/client-generator/src/generators/go/AGENTS.md b/packages/client-generator/src/generators/go/AGENTS.md index 562e69ba52..335496da53 100644 --- a/packages/client-generator/src/generators/go/AGENTS.md +++ b/packages/client-generator/src/generators/go/AGENTS.md @@ -20,6 +20,10 @@ runtime. Go ≥ 1.21, standard library only — zero dependencies. Go's own rule (lowercase letters, digits, `_`, no leading digit, not a keyword) and an invalid one fails generation: silently rewriting a publisher's package name would be worse than saying no. +- **Doc comments are gofmt's shape**, not the description's: a blank line prints as `//` + (never `// `, which gofmt strips), and CONSECUTIVE blank lines collapse to one — gofmt + rewrites `//\n//` to a single `//`, so emitting both means our output is not + gofmt-clean. Descriptions with a double blank line are common in real specs. - **Naming:** exported PascalCase via `identifierFor` + an `N` prefix for digit-leading names (`3ds` → `N3ds` — an `_`-prefixed field is unexported and invisible to `encoding/json`); `+1`/`-1` become `Plus1`/`Minus1`. diff --git a/packages/client-generator/src/generators/go/index.ts b/packages/client-generator/src/generators/go/index.ts index 3d85c6e30d..260b6d08c8 100644 --- a/packages/client-generator/src/generators/go/index.ts +++ b/packages/client-generator/src/generators/go/index.ts @@ -107,8 +107,18 @@ function writeDocComment(printer: Printer, name: string, description?: string): 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. - for (const line of lines.slice(1)) printer.line(line === '' ? '//' : `// ${line}`); + // 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( @@ -145,6 +155,15 @@ 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'); @@ -165,7 +184,7 @@ export function renderGoModels(model: ApiModel, dateType: DateType = 'string'): printer.blank(); } printer.line(body); - return alignGoColumns(printer.toString()); + return gofmtShape(alignGoColumns(printer.toString())); } /** The struct/enum/union declarations themselves — the header is renderGoModels' job. */ @@ -1078,7 +1097,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: `${alignGoColumns(printer.toString().replace(/\n{3,}/g, '\n\n')).trimEnd()}\n`, + content: gofmtShape(alignGoColumns(printer.toString())), }, ]; }; diff --git a/tests/e2e/generate-client/generator-contract.test.ts b/tests/e2e/generate-client/generator-contract.test.ts index bfcecf1ef0..f6c9a1ce01 100644 --- a/tests/e2e/generate-client/generator-contract.test.ts +++ b/tests/e2e/generate-client/generator-contract.test.ts @@ -5,7 +5,7 @@ import { spawnSync } from 'node:child_process'; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import { outdent } from 'outdent'; import { cliEntry, repoRoot, tscBin } from './helpers.js'; @@ -22,6 +22,24 @@ function run(args: string[]): { status: number | null; out: string } { return { status: res.status, out: `${res.stdout}\n${res.stderr}` }; } +// Every built-in must be discoverable from `--help`: an inline list that goes stale is +// what made four separate reports say the languages "aren't supported". +describe('generate-client --help', () => { + it('names every built-in generator', async () => { + // The metadata table is the registry the pipeline resolves against — the one list + // `--help` must not fall behind. + const { BUILTIN_META } = await import( + pathToFileURL(join(repoRoot, 'packages/client-generator/lib/generators/meta.js')).href + ); + // yargs wraps help text mid-token (`tanstack-query-v\nue`), so compare with the + // whitespace removed — a generator name never contains any. + const help = run(['--help']).out.replace(/\s+/g, ''); + for (const name of Object.keys(BUILTIN_META as Record)) { + expect(help, `--help does not mention the "${name}" generator`).toContain(name); + } + }, 60_000); +}); + describe('generate-client generator compatibility contract', () => { it('pulls in the sdk a wrapper generator needs instead of failing', () => { const dir = mkdtempSync(join(tmpdir(), 'ots-contract-')); From 340f195c68eb78a7b9ead706308654d934b46576 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Fri, 7 Aug 2026 09:28:25 +0300 Subject: [PATCH 120/211] fix: escape only what JSON.stringify leaves unsafe in a code context --- .changeset/code-literal-escaping.md | 5 ++++ .../src/emitters/__tests__/ts-literal.test.ts | 26 ++++++++++++++++++- .../client-generator/src/emitters/cli-docs.ts | 6 +---- .../src/emitters/ts-literal.ts | 20 +++++++------- 4 files changed, 40 insertions(+), 17 deletions(-) create mode 100644 .changeset/code-literal-escaping.md diff --git a/.changeset/code-literal-escaping.md b/.changeset/code-literal-escaping.md new file mode 100644 index 0000000000..5f21ab0207 --- /dev/null +++ b/.changeset/code-literal-escaping.md @@ -0,0 +1,5 @@ +--- +'@redocly/client-generator': patch +--- + +Fixed string escaping in generated code: a value containing a quote or a newline was double-escaped, which ended the string early and produced TypeScript that did not parse. diff --git a/packages/client-generator/src/emitters/__tests__/ts-literal.test.ts b/packages/client-generator/src/emitters/__tests__/ts-literal.test.ts index 03dfe6205e..540ff8e705 100644 --- a/packages/client-generator/src/emitters/__tests__/ts-literal.test.ts +++ b/packages/client-generator/src/emitters/__tests__/ts-literal.test.ts @@ -1,4 +1,4 @@ -import { codeLiteral } from '../ts-literal.js'; +import { codeLiteral, sanitizeCodeString } from '../ts-literal.js'; // Literal expectations for the data-literal renderer (single-line, printer-style). const CASES: Array<[string, unknown]> = [ @@ -35,3 +35,27 @@ describe('codeLiteral', () => { 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/cli-docs.ts b/packages/client-generator/src/emitters/cli-docs.ts index 3ff15b13fc..a47897e400 100644 --- a/packages/client-generator/src/emitters/cli-docs.ts +++ b/packages/client-generator/src/emitters/cli-docs.ts @@ -19,11 +19,7 @@ export type CliDocsOptions = { /** Table-cell-safe text: one line, and pipes/backslashes escaped so they don't alter columns/escaping. */ function cell(text: string | undefined): string { - return (text ?? '') - .replace(/\s+/g, ' ') - .trim() - .replace(/\\/g, '\\\\') - .replace(/\|/g, '\\|'); + return (text ?? '').replace(/\s+/g, ' ').trim().replace(/\\/g, '\\\\').replace(/\|/g, '\\|'); } /** How a command is typed at the prompt: ` `, or just ``. */ diff --git a/packages/client-generator/src/emitters/ts-literal.ts b/packages/client-generator/src/emitters/ts-literal.ts index 5978f087af..86008af0b9 100644 --- a/packages/client-generator/src/emitters/ts-literal.ts +++ b/packages/client-generator/src/emitters/ts-literal.ts @@ -4,24 +4,22 @@ import { isIdentifier } from './identifier.js'; -const UNSAFE_STRING_CHARS = /[<>\/\\\b\f\n\r\t\0\u2028\u2029]/g; -const UNSAFE_STRING_CHAR_MAP: Record = { +// `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', - '/': '\\u002F', - '\\': '\\\\', - '\b': '\\b', - '\f': '\\f', - '\n': '\\n', - '\r': '\\r', - '\t': '\\t', - '\0': '\\0', '\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(UNSAFE_STRING_CHARS, (char) => UNSAFE_STRING_CHAR_MAP[char] ?? char); + return JSON.stringify(value).replace(/[<>\u2028\u2029]/g, (char) => CODE_UNSAFE[char]); } /** A JSON-ish value as TypeScript source text. */ From 8dffbce587027af25cfb7554891f103ad4bd370c Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Fri, 7 Aug 2026 12:14:00 +0300 Subject: [PATCH 121/211] fix: carry the built-in contract into ejected generators An ejected asset stamped only name, run, sample, and requiresGenerator, so an ejected cli or cli-docs stopped pulling its prerequisites, enforcing throw-mode, and validating its options. The eject script now stamps the contract from BUILTIN_META itself, and passes cli-docs its options schema. Prerequisites were also expanded before import specifiers were loaded, so a path generator's requires was never read. resolveGenerators now loads every selected entry first, which also stops a built-in prerequisite from colliding with an ejected generator of the same name. --- .../scripts/generate-eject-assets.mjs | 80 +++++++++++++++--- .../src/generators/__tests__/resolve.test.ts | 9 ++ .../src/generators/resolve.ts | 82 +++++++++---------- 3 files changed, 117 insertions(+), 54 deletions(-) diff --git a/packages/client-generator/scripts/generate-eject-assets.mjs b/packages/client-generator/scripts/generate-eject-assets.mjs index 3c0b244b90..966ab7eb61 100644 --- a/packages/client-generator/scripts/generate-eject-assets.mjs +++ b/packages/client-generator/scripts/generate-eject-assets.mjs @@ -2,7 +2,7 @@ import { build } from 'esbuild'; import { spawnSync } from 'node:child_process'; import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { fileURLToPath, pathToFileURL } from 'node:url'; import ts from 'typescript'; import { ejectedSkill } from './ejected-skill.mjs'; @@ -55,15 +55,64 @@ function provenanceHeader(name) { ); } -/** The default export the resolver loads, appended to every asset. */ -function defaultExport(name, run, sample) { - return ( - `\nexport default {\n name: '${name}',\n run: ${run},\n` + - (sample === undefined ? '' : ` sample: ${sample},\n`) + - // The caret range the ejected copy was written against: this version's model and - // helpers, plus every compatible release after it. - ` requiresGenerator: '^${version}',\n};\n` - ); +/** + * The built-in compatibility table, read from its own source so an ejected file cannot + * declare a different contract from the built-in it came from. `load` is never called, + * so the generator modules it dynamic-imports are left unresolved. + */ +async function loadBuiltinMeta() { + const bundle = join(pkgRoot, 'eject-assets', '.meta.mjs'); + await build({ + entryPoints: [join(pkgRoot, 'src', 'generators', 'meta.ts')], + outfile: bundle, + bundle: true, + format: 'esm', + platform: 'node', + target: 'node20', + external: ['@redocly/openapi-core'], + plugins: [ + { + name: 'skip-generator-modules', + setup: (pluginBuild) => + pluginBuild.onResolve({ filter: /\/index\.js$/ }, (args) => ({ + path: args.path, + external: true, + })), + }, + ], + logLevel: 'warning', + }); + try { + return (await import(pathToFileURL(bundle).href)).BUILTIN_META; + } finally { + rmSync(bundle, { force: true }); + } +} + +const BUILTIN_META = await loadBuiltinMeta(); + +/** + * The default export the resolver loads, appended to every asset. It carries the same + * contract the built-in declares — `requires`, `errorModes`, `dateTypes`, `runtimes`, + * `notApplicable` — so an ejected generator still pulls its prerequisites in and is + * validated exactly like the built-in it replaces. + */ +function defaultExport(name, run, sample, options) { + const { load: _load, ...contract } = BUILTIN_META[name]; + const fields = [` name: '${name}',`, ` run: ${run},`]; + if (sample !== undefined) fields.push(` sample: ${sample},`); + if (options !== undefined) fields.push(` options: ${options},`); + for (const [key, value] of Object.entries(contract)) { + // Wrapped only when it would run long — the user owns and edits this file. + const inline = JSON.stringify(value); + const text = + inline.length <= 80 ? inline : JSON.stringify(value, null, 2).replaceAll('\n', '\n '); + fields.push(` ${key}: ${text},`); + } + // The caret range the ejected copy was written against: this version's model and + // helpers, plus every compatible release after it. + fields.push(` requiresGenerator: '^${version}',`); + return `\nexport default {\n${fields.join('\n')}\n};\n`; } /** Fail the build loudly — a broken asset would only surface in a user's repo. */ @@ -100,7 +149,12 @@ const TYPESCRIPT = [ { name: 'swr', imports: ['swrGenerator'], run: 'swrGenerator' }, { name: 'transformers', imports: ['transformersGenerator'], run: 'transformersGenerator' }, { name: 'cli', imports: ['cliGenerator', 'cliSample'], run: 'cliGenerator', sample: 'cliSample' }, - { name: 'cli-docs', imports: ['cliDocsGenerator'], run: 'cliDocsGenerator' }, + { + name: 'cli-docs', + imports: ['cliDocsGenerator', 'cliDocsOptions'], + run: 'cliDocsGenerator', + options: 'cliDocsOptions', + }, { name: 'tanstack-query', imports: ['tanstackQueryGenerator'], @@ -108,7 +162,7 @@ const TYPESCRIPT = [ }, ]; -for (const { name, imports, run, sample } of TYPESCRIPT) { +for (const { name, imports, run, sample, options } 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. @@ -117,7 +171,7 @@ for (const { name, imports, run, sample } of TYPESCRIPT) { entry, `import { ${imports.join(', ')} } from ${JSON.stringify( join(pkgRoot, 'src', 'generators', name, 'index.ts') - )};\n` + defaultExport(name, run, sample) + )};\n` + defaultExport(name, run, sample, options) ); const outFile = join(outDir, `${name}.mjs`); try { diff --git a/packages/client-generator/src/generators/__tests__/resolve.test.ts b/packages/client-generator/src/generators/__tests__/resolve.test.ts index 1c084d65ab..837e7db57f 100644 --- a/packages/client-generator/src/generators/__tests__/resolve.test.ts +++ b/packages/client-generator/src/generators/__tests__/resolve.test.ts @@ -130,6 +130,15 @@ describe('resolveGenerators', () => { expect(registry.has('route-map')).toBe(true); }); + it('pulls in the prerequisite a path-loaded generator declares', async () => { + // The specifier has to be imported before its `requires` is known, so an ejected + // generator gets its prerequisites the same way the built-in name does. + const { selected } = await resolveGenerators(['./route-map-plugin.ts'], { + configDir: fixtures, + }); + expect(selected).toEqual(['sdk', 'route-map']); + }); + it('rejects URL specifiers — remote generator modules are not supported', async () => { // Mirrors core's plugin loading; a `data:` URL would otherwise reach `import()` // and execute inline code straight from the config. diff --git a/packages/client-generator/src/generators/resolve.ts b/packages/client-generator/src/generators/resolve.ts index 0cdad2776e..2ee13f5e43 100644 --- a/packages/client-generator/src/generators/resolve.ts +++ b/packages/client-generator/src/generators/resolve.ts @@ -41,55 +41,55 @@ export async function resolveGenerators( const registry = new Map(); for (const custom of options.customGenerators ?? []) register(registry, custom); - const selected: string[] = []; + // Load every selected entry first: an import specifier's declared name and `requires` + // are only known once it is imported, and an ejected generator carries the same + // `requires` as the built-in it replaces. + const names: string[] = []; + for (const entry of entries) { + names.push(await loadEntry(entry, registry, options.configDir)); + } + // A prerequisite is pulled in rather than demanded: selecting `cli` should give a // working CLI without the user knowing which other generators provide its parts. - const entriesWithPrerequisites = expandPrerequisites(entries, options.customGenerators); - for (const entry of entriesWithPrerequisites) { - if (registry.has(entry)) { - selected.push(entry); - continue; - } - const meta = (BUILTIN_META as Record)[entry]; - if (meta !== undefined) { - const { load, ...compatibility } = meta; - registry.set(entry, { ...compatibility, ...(await load()) }); - selected.push(entry); - continue; + const selected: string[] = []; + const visiting = new Set(); + const add = async (name: string): Promise => { + if (selected.includes(name) || visiting.has(name)) return; + visiting.add(name); + for (const required of registry.get(name)!.requires ?? []) { + // Only pull in a prerequisite we know how to load — an already-registered + // generator or a built-in. Anything else stays the user's problem and is + // reported by `validateSelection`. + if (registry.has(required) || required in BUILTIN_META) { + await add(await loadEntry(required, registry, options.configDir)); + } } - const custom = await importGenerator(entry, options.configDir ?? process.cwd()); - register(registry, custom); - selected.push(custom.name); - } + visiting.delete(name); + selected.push(name); + }; + for (const name of names) await add(name); return { selected, registry }; } /** - * The selection with every declared prerequisite included, each before the generator that - * needs it. Only BUILT-IN prerequisites are added: a custom generator's `requires` may - * name anything, and inventing a resolution for it would be guesswork. + * Load one entry — an already-registered name, a built-in name, or an import specifier — + * into the registry, and return the name it is registered under. */ -function expandPrerequisites(entries: string[], customs: CustomGenerator[] = []): string[] { - const requirementsOf = (name: string): string[] => { - const meta = (BUILTIN_META as Record)[name]; - if (meta !== undefined) return meta.requires ?? []; - return customs.find((custom) => custom.name === name)?.requires ?? []; - }; - const out: string[] = []; - const visiting = new Set(); - const add = (name: string): void => { - if (out.includes(name) || visiting.has(name)) return; - visiting.add(name); - for (const required of requirementsOf(name)) { - // Only auto-add a prerequisite we know how to load; anything else stays the - // user's problem and is reported by `validateSelection`. - if (required in BUILTIN_META) add(required); - } - visiting.delete(name); - if (!out.includes(name)) out.push(name); - }; - for (const entry of entries) add(entry); - return out; +async function loadEntry( + entry: string, + registry: Map, + configDir?: string +): Promise { + if (registry.has(entry)) return entry; + const meta = (BUILTIN_META as Record)[entry]; + if (meta !== undefined) { + const { load, ...compatibility } = meta; + registry.set(entry, { ...compatibility, ...(await load()) }); + return entry; + } + const custom = await importGenerator(entry, configDir ?? process.cwd()); + register(registry, custom); + return custom.name; } /** Validate a custom generator and add it under its name, rejecting collisions. */ From 90003ad57c80aa258a4ca5dd68f760e5042b0247 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Fri, 7 Aug 2026 12:14:14 +0300 Subject: [PATCH 122/211] fix(cli): count cli-docs as a built-in generator in telemetry cli-docs was missing from the allowlist, so its runs were counted as custom and an ejected cli-docs provenance header was ignored. The test claimed to cover every built-in while checking seven of fourteen; it now compares the allowlist against the ejectable set plus the framework variants. --- .../src/__tests__/generate-client-telemetry.test.ts | 11 +++++++---- packages/cli/src/commands/eject-generator.ts | 4 ++-- packages/cli/src/utils/generate-client-telemetry.ts | 1 + 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/__tests__/generate-client-telemetry.test.ts b/packages/cli/src/__tests__/generate-client-telemetry.test.ts index 2de75a73d5..78836d16dd 100644 --- a/packages/cli/src/__tests__/generate-client-telemetry.test.ts +++ b/packages/cli/src/__tests__/generate-client-telemetry.test.ts @@ -1,3 +1,4 @@ +import { EJECTABLE, FRAMEWORK_VARIANTS } from '../commands/eject-generator.js'; import { BUILTIN_GENERATOR_NAMES, categorizeGenerateClientError, @@ -47,10 +48,12 @@ describe('categorizeGenerateClientError', () => { }); describe('BUILTIN_GENERATOR_NAMES', () => { - it('covers every current built-in — a missing name silently degrades the usage event', () => { - for (const name of ['sdk', 'zod', 'mock', 'cli', 'python', 'go', 'php']) { - expect(BUILTIN_GENERATOR_NAMES.has(name), name).toBe(true); - } + // Every built-in ships as a vendorable asset, so EJECTABLE plus the framework variants + // is the full set. A built-in missing here is counted as a custom generator and its + // ejected provenance header is ignored. + it('covers every built-in', () => { + const builtins = [...EJECTABLE, ...FRAMEWORK_VARIANTS.keys()].sort(); + expect([...BUILTIN_GENERATOR_NAMES].sort()).toEqual(builtins); }); }); diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts index 1a9e3cd297..de5cc95772 100644 --- a/packages/cli/src/commands/eject-generator.ts +++ b/packages/cli/src/commands/eject-generator.ts @@ -25,7 +25,7 @@ export type EjectGeneratorCommandArgv = { }; /** Every built-in generator ships as a vendorable asset. */ -const EJECTABLE = new Set([ +export const EJECTABLE = new Set([ 'python', 'go', 'php', @@ -44,7 +44,7 @@ const EJECTABLE = new Set([ * single argument in the ejected file — so they point at the base generator instead of * shipping four near-identical bundles. */ -const FRAMEWORK_VARIANTS = new Map([ +export const FRAMEWORK_VARIANTS = new Map([ ['tanstack-query-vue', 'vue'], ['tanstack-query-svelte', 'svelte'], ['tanstack-query-solid', 'solid'], diff --git a/packages/cli/src/utils/generate-client-telemetry.ts b/packages/cli/src/utils/generate-client-telemetry.ts index 9bf861de90..5df66d12a6 100644 --- a/packages/cli/src/utils/generate-client-telemetry.ts +++ b/packages/cli/src/utils/generate-client-telemetry.ts @@ -27,6 +27,7 @@ export const BUILTIN_GENERATOR_NAMES = new Set([ 'transformers', 'mock', 'cli', + 'cli-docs', 'python', 'go', 'php', From a8d986e355e3f7bb7fd65eca3f7fb84749ff3189 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Fri, 7 Aug 2026 12:20:07 +0300 Subject: [PATCH 123/211] fix: keep the eject-asset build off compiled output The build read the built-in table with @redocly/openapi-core left external, so the generated module resolved into packages/core/lib. That directory does not exist yet on prepare, which runs before compile, and a clean install failed. meta.ts only touches openapi-core inside functions this build never calls, so the import is stubbed out instead. --- .../scripts/generate-eject-assets.mjs | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/client-generator/scripts/generate-eject-assets.mjs b/packages/client-generator/scripts/generate-eject-assets.mjs index 966ab7eb61..e8cafb201e 100644 --- a/packages/client-generator/scripts/generate-eject-assets.mjs +++ b/packages/client-generator/scripts/generate-eject-assets.mjs @@ -57,8 +57,11 @@ function provenanceHeader(name) { /** * The built-in compatibility table, read from its own source so an ejected file cannot - * declare a different contract from the built-in it came from. `load` is never called, - * so the generator modules it dynamic-imports are left unresolved. + * declare a different contract from the built-in it came from. Only the metadata is + * wanted, so everything the table reaches for at call time is cut away: the generator + * modules behind `load`, and `@redocly/openapi-core`, which `meta.ts` uses only inside + * functions we never call. Nothing here may resolve into a package's `lib/` — this runs + * on `prepare`, before anything is compiled. */ async function loadBuiltinMeta() { const bundle = join(pkgRoot, 'eject-assets', '.meta.mjs'); @@ -69,15 +72,22 @@ async function loadBuiltinMeta() { format: 'esm', platform: 'node', target: 'node20', - external: ['@redocly/openapi-core'], plugins: [ { - name: 'skip-generator-modules', - setup: (pluginBuild) => + name: 'cut-call-time-imports', + setup(pluginBuild) { pluginBuild.onResolve({ filter: /\/index\.js$/ }, (args) => ({ path: args.path, external: true, - })), + })); + pluginBuild.onResolve({ filter: /^@redocly\/openapi-core$/ }, () => ({ + path: 'openapi-core', + namespace: 'unused-at-build-time', + })); + pluginBuild.onLoad({ filter: /.*/, namespace: 'unused-at-build-time' }, () => ({ + contents: 'export const logger = {};', + })); + }, }, ], logLevel: 'warning', From 4637d76f9a261c01b5e6a4025017d3e91cebfa42 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Fri, 7 Aug 2026 15:51:51 +0300 Subject: [PATCH 124/211] docs: correct changelog --- .changeset/agent-friendly-generators.md | 2 +- .changeset/cli-docs-generator.md | 8 -------- .changeset/cli-dry-run-response-validation.md | 5 ----- .changeset/code-literal-escaping.md | 5 ----- .changeset/go-doc-comment-blank-lines.md | 5 ----- 5 files changed, 1 insertion(+), 24 deletions(-) delete mode 100644 .changeset/cli-docs-generator.md delete mode 100644 .changeset/cli-dry-run-response-validation.md delete mode 100644 .changeset/code-literal-escaping.md delete mode 100644 .changeset/go-doc-comment-blank-lines.md diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index c70b93cae6..827fe4600a 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -3,4 +3,4 @@ '@redocly/cli': minor --- -Added agent-friendly client generation: `python`, `go`, `php`, and `cli` generators beside the TypeScript ones, a language-neutral authoring toolkit with per-generator options, and an `eject-generator` command that vendors any built-in generator — plus its design as an agent skill — into your repo. +Added agent-friendly client generation: `python`, `go`, `php`, `cli`, and `cli-docs` generators beside the TypeScript ones, a language-neutral authoring toolkit with per-generator options, and an `eject-generator` command that vendors any built-in generator — plus its design as an agent skill — into your repo. diff --git a/.changeset/cli-docs-generator.md b/.changeset/cli-docs-generator.md deleted file mode 100644 index e4fc4475aa..0000000000 --- a/.changeset/cli-docs-generator.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -'@redocly/client-generator': minor -'@redocly/cli': minor ---- - -Added a `cli-docs` generator that writes the Markdown reference for the generated CLI — every command, flag, credential variable, and exit code — rendered from the same command table the CLI dispatches on. - -An operation whose request body the CLI cannot build (multipart, url-encoded, binary) now says so in both its `--help` and its reference entry, instead of appearing runnable. diff --git a/.changeset/cli-dry-run-response-validation.md b/.changeset/cli-dry-run-response-validation.md deleted file mode 100644 index def79a798e..0000000000 --- a/.changeset/cli-dry-run-response-validation.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@redocly/client-generator': patch ---- - -Fixed the generated CLI reporting response-validation drift under `--dry-run`, where the only response is the dry-run stub; request validation still runs. diff --git a/.changeset/code-literal-escaping.md b/.changeset/code-literal-escaping.md deleted file mode 100644 index 5f21ab0207..0000000000 --- a/.changeset/code-literal-escaping.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@redocly/client-generator': patch ---- - -Fixed string escaping in generated code: a value containing a quote or a newline was double-escaped, which ended the string early and produced TypeScript that did not parse. diff --git a/.changeset/go-doc-comment-blank-lines.md b/.changeset/go-doc-comment-blank-lines.md deleted file mode 100644 index 34fd61585b..0000000000 --- a/.changeset/go-doc-comment-blank-lines.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@redocly/client-generator': patch ---- - -Fixed the Go SDK emitting two blank comment lines where a description has consecutive blank lines, which left the output not gofmt-clean. From e0393670058c7f579445411fd7b3a5add9c7aac7 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Fri, 7 Aug 2026 16:46:48 +0300 Subject: [PATCH 125/211] fix(cli): report an outcome when eject-generator fails unexpectedly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An error no branch accounts for — an unreadable asset, a failed write — left eject_generator_outcome unset, so the event could not be told apart from a bug in the collector. The outcome now starts at unexpected-error and every path that finishes overwrites it. Ejecting also set success before wiring the dependency and the config entry, so a failure there was reported as a successful eject; it is set last now. --- .../__tests__/commands/eject-generator.test.ts | 16 ++++++++++++++++ packages/cli/src/commands/eject-generator.ts | 6 +++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/__tests__/commands/eject-generator.test.ts b/packages/cli/src/__tests__/commands/eject-generator.test.ts index 64933deaae..3dc5f47bce 100644 --- a/packages/cli/src/__tests__/commands/eject-generator.test.ts +++ b/packages/cli/src/__tests__/commands/eject-generator.test.ts @@ -30,6 +30,22 @@ describe('eject telemetry (coarse categories only)', () => { }); }); + it('a failure we did not account for still records an outcome', async () => { + // The shipped assets sit next to the BUILT module, so reading one from source fails + // the same way a broken install would — an error no branch sets an outcome for. + await expect( + handleEjectGenerator({ + ...baseArgs, + argv: { generator: 'php', update: true }, + } as CommandArgs) + ).rejects.toThrow(); + expect(ejectGeneratorTelemetry).toEqual({ + eject_generator_action: 'update', + eject_generator_name: 'php', + eject_generator_outcome: 'unexpected-error', + }); + }); + it('an unknown generator records the outcome but never the user-supplied name', async () => { await expect( handleEjectGenerator({ diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts index de5cc95772..c1b5b49322 100644 --- a/packages/cli/src/commands/eject-generator.ts +++ b/packages/cli/src/commands/eject-generator.ts @@ -274,6 +274,9 @@ export const handleEjectGenerator = async ({ if (EJECTABLE.has(name) || FRAMEWORK_VARIANTS.has(name)) { ejectGeneratorTelemetry.eject_generator_name = name; } + // Every path that finishes overwrites this, so it survives only when something we did + // not account for throws — an unreadable asset, a failed write, a missing directory. + ejectGeneratorTelemetry.eject_generator_outcome = 'unexpected-error'; const framework = FRAMEWORK_VARIANTS.get(name); if (framework !== undefined) { ejectGeneratorTelemetry.eject_generator_action = 'guidance'; @@ -363,7 +366,6 @@ export const handleEjectGenerator = async ({ const authoringSkill = dropSkill('client-generators', assetsDir); const designSkill = dropSkill(`${name}-generator`, assetsDir); dropPointer(dir, ejectedIn(dir)); - ejectGeneratorTelemetry.eject_generator_outcome = 'success'; const configEntry = `./${relative(process.cwd(), target).split('\\').join('/')}`; const dependency = wireDependency({ [TOOLKIT_PACKAGE]: `^${toolkitVersion}` }); // A bundled TypeScript generator also imports `logger`/`isPlainObject` from core, which @@ -386,4 +388,6 @@ export const handleEjectGenerator = async ({ ` client:\n generators:\n - ${configEntry}\n\n`) + `Your agent's skills: ${designSkill} (this generator's design) and ${authoringSkill} (the toolkit).\n` ); + // Last, so wiring the dependency or the config entry failing is not reported as success. + ejectGeneratorTelemetry.eject_generator_outcome = 'success'; }; From 771b9dd03535500975333c4818fcf92724e92e07 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sat, 8 Aug 2026 09:44:43 +0300 Subject: [PATCH 126/211] docs: restore the rule test pattern and the package architecture map --- .claude/rules/architecture.md | 47 +++++++++++++++++++++++++++++++++++ .claude/rules/testing.md | 36 ++++++++++++++++++++++++++- AGENTS.md | 8 +++++- 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 .claude/rules/architecture.md diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md new file mode 100644 index 0000000000..faf24da081 --- /dev/null +++ b/.claude/rules/architecture.md @@ -0,0 +1,47 @@ +# Repository architecture + +Where things live, so a change lands in the right package. + +This is a TypeScript monorepo with npm workspaces containing four packages: + +## `packages/core` (@redocly/openapi-core) + +The heart of the project. +Handles all OpenAPI/AsyncAPI linting, validation, bundling, and decoration logic. +This package is also used in external apps such as `language-server` and `vs-code-extension`. + +Key directories: + +- `src/rules/` — Built-in linting rules, organized by spec type (`oas2/`, `oas3/`, `oas3_1/`, `async2/`, `async3/`, `arazzo/`, `common/`). Each rule is its own file. +- `src/config/` — Configuration loading and resolution (reads `redocly.yaml`). +- `src/decorators/` — Built-in decorators for transforming API descriptions. +- `src/bundle/` — Bundling logic that resolves `$ref` across multiple files. +- `src/resolve.ts` — Document resolution for multi-file specs (local and remote). +- `src/types/` — TypeScript type definitions for OAS2, OAS3, AsyncAPI, Arazzo. + +## `packages/cli` (@redocly/cli) + +User-facing CLI layer built on top of core. +Uses yargs for argument parsing. + +- `src/index.ts` — Main command dispatcher. +- `src/commands/` — One file per command. +- Commands use `commandWrapper()` for consistent output, config loading, config linting, and exit codes (0 = success, 1 = execution error, 2 = config error). + +## `packages/respect-core` (@redocly/respect-core) + +API contract testing framework. +Validates real API responses against OpenAPI/Arazzo specs. + +- `src/run.ts` — Test execution logic. +- `src/modules/` — Core testing modules, including runtime expression evaluation. + +## `packages/client-generator` (@redocly/client-generator) + +Experimental package for generating clients from OpenAPI descriptions — the TypeScript client +plus the `python`, `go`, and `php` SDKs, the generated CLI, and its Markdown reference. + +- `src/intermediate-representation/` — the language-neutral API model every generator reads. +- `src/emitters/` — the renderers that turn that model into source text. +- `src/generators/` — one folder per generator: a thin entry plus the design skill it must match. +- `src/authoring/` — the language-neutral toolkit generators are written with, ours and users'. diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index e6e07935cb..6cefda53d7 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -2,10 +2,44 @@ 1. Write meaningful tests that exercise real behavior — not tests that exist only to raise coverage. One focused, clear test is enough. -1. Rule tests are unit tests by convention: parse a YAML document, run `lintDocument`, and assert with `toMatchInlineSnapshot` — a behavior test in itself (given this input, these problems). +1. Rule tests are unit tests by convention: parse a YAML document, run `lintDocument`, and assert + with `toMatchInlineSnapshot` — a behavior test in itself (given this input, these problems). Generate new snapshots and update stale ones as part of the change. + + The pattern — parse, lint, assert on the whole output: + + ```ts + import { outdent } from 'outdent'; + import { parseYamlToDocument, replaceSourceWithRef } from '../../../../__tests__/utils.js'; + import { createConfig } from '../../../config/index.js'; + import { lintDocument } from '../../../lint.js'; + import { BaseResolver } from '../../../resolve.js'; + + describe('Oas3 no-my-rule', () => { + it('should report a violation', async () => { + const document = parseYamlToDocument( + outdent` + openapi: 3.0.0 + ... + `, + 'foobar.yaml' + ); + + const results = await lintDocument({ + externalRefResolver: new BaseResolver(), + document, + config: await createConfig({ rules: { 'no-my-rule': 'error' } }), + }); + + expect(replaceSourceWithRef(results)).toMatchInlineSnapshot(`...`); + }); + }); + ``` + 1. Compile before testing. + Unit tests import from `lib/` (compiled output), not `src/` — run `npm run compile` after every change. + 1. Run the full suite (`npm test`) when you touch core linting logic, and make sure all tests pass in CI. 1. Client generation has its own suite: `npm run generators` runs the client-generator unit tests plus the `tests/e2e/generate-client` bars (which compile real Python/Go/PHP/TypeScript output). Run it for any generation change; `npm run e2e` no longer includes those tests. diff --git a/AGENTS.md b/AGENTS.md index c1d3e21337..d3db1b44ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,6 +65,12 @@ npm run format npm run cli -- lint openapi.yaml ``` +## Architecture + +Where each package sits, and the key directories inside it, are in +[`.claude/rules/architecture.md`](./.claude/rules/architecture.md) — read it before a change lands +in the wrong package. + ## Build System `packages/core` and `packages/respect-core` are compiled by TypeScript (`tsc -b tsconfig.build.json`). @@ -109,7 +115,7 @@ Naming and reuse: - Run the full suite (`npm test`) when you touch core linting logic. - Run `npm run generators` when you touch client generation — it is the whole generator suite in one command. -The full testing and QA rules are in +The full testing and QA rules — including the rule test pattern to copy — are in [`.claude/rules/testing.md`](./.claude/rules/testing.md). ## Code quality — no AI slop From 7e8bb056f8a4ee819f7522f8078d59852e67e886 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sat, 8 Aug 2026 09:58:36 +0300 Subject: [PATCH 127/211] feat: warn when binName or goPackage is set with no generator that reads it --- .../src/generators/__tests__/index.test.ts | 20 ++++++++++++++++++ .../client-generator/src/generators/meta.ts | 21 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/packages/client-generator/src/generators/__tests__/index.test.ts b/packages/client-generator/src/generators/__tests__/index.test.ts index 621d300add..4e88714d31 100644 --- a/packages/client-generator/src/generators/__tests__/index.test.ts +++ b/packages/client-generator/src/generators/__tests__/index.test.ts @@ -93,6 +93,26 @@ describe('validateGenerators', () => { } }); + it('warns when a single-generator option is set without its generator', () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); + try { + validateGenerators(['python'], { goPackage: 'mypkg' }); + validateGenerators(['go'], { binName: 'cafe-api' }); + const messages = warn.mock.calls.map(([message]) => message).join(''); + expect(messages).toContain('goPackage is ignored'); + expect(messages).toContain('binName is ignored'); + + // The generator that reads it is selected, so nothing to say — even alongside + // generators that don't read it. + warn.mockClear(); + validateGenerators(['sdk', 'zod', 'cli'], { binName: 'cafe-api' }); + validateGenerators(['go'], { goPackage: 'mypkg' }); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + it('throws NotSupportedError for an unknown generator name', () => { expect(() => validateGenerators(['nope' as never], {})).toThrow(NotSupportedError); }); diff --git a/packages/client-generator/src/generators/meta.ts b/packages/client-generator/src/generators/meta.ts index 2daccdc846..d81c483c8d 100644 --- a/packages/client-generator/src/generators/meta.ts +++ b/packages/client-generator/src/generators/meta.ts @@ -118,6 +118,16 @@ export const BUILTIN_META: Record = { }, }; +/** Options a single generator reads, so setting one without it selected is a no-op. */ +const SINGLE_GENERATOR_OPTIONS: { + option: 'binName' | 'goPackage'; + generators: GeneratorName[]; + reason: string; +}[] = [ + { option: 'binName', generators: ['cli', 'cli-docs'], reason: 'it names the generated command' }, + { option: 'goPackage', generators: ['go'], reason: 'it declares the Go package clause' }, +]; + /** * Validate a generator selection against every selected generator's declared * contract, throwing the first violation with an actionable message. Runs before @@ -133,6 +143,17 @@ export function validateSelection( outputMode?: OutputMode ): void { const selected = new Set(names); + // Options only one generator reads. `notApplicable` can't express this: it fires per + // generator, so marking `binName` on `sdk` would warn on `--generator sdk --generator + // cli`, where `cli` does apply it. Setting one with none of its generators selected + // does nothing at all, which is worth saying. + for (const { option, generators, reason } of SINGLE_GENERATOR_OPTIONS) { + if (emit[option] !== undefined && !generators.some((generator) => selected.has(generator))) { + logger.warn( + `generate-client: ${option} is ignored — ${reason}, and no selected generator uses it (add --generator ${generators[0]}).\n` + ); + } + } const errorMode = emit.errorMode ?? 'throw'; const dateType = emit.dateType ?? 'string'; const runtime = emit.runtime ?? 'inline'; From 3a61c3ba8851ba7b05a0e8d1034be2ce9af0adf7 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sat, 8 Aug 2026 16:37:45 +0300 Subject: [PATCH 128/211] fix: apply readOnly declared beside a $ref in OpenAPI 3.1 --- .changeset/ref-sibling-readonly.md | 6 ++ .../@v2/guides/customize-client-generation.md | 7 +- docs/@v2/guides/use-generated-client.md | 16 +++++ .../__tests__/build.test.ts | 69 +++++++++++++++++++ .../src/intermediate-representation/build.ts | 23 ++++++- 5 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 .changeset/ref-sibling-readonly.md diff --git a/.changeset/ref-sibling-readonly.md b/.changeset/ref-sibling-readonly.md new file mode 100644 index 0000000000..261b593f1c --- /dev/null +++ b/.changeset/ref-sibling-readonly.md @@ -0,0 +1,6 @@ +--- +'@redocly/client-generator': patch +'@redocly/cli': patch +--- + +Fixed `readOnly` being ignored when it sits beside a `$ref` in an OpenAPI 3.1 description, which left server-computed properties in generated request bodies; in 3.0, where a `$ref` replaces the schema, generation now warns instead of dropping the keyword silently. diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index 6a7c0f62b4..7eb44af376 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -86,13 +86,18 @@ The API model and the helper library are the generator contract, and it changes Declare the version you authored against with `requiresGenerator: '^1.2.0'`, and an incompatible CLI fails upfront — naming the version it has, the version you need, and the upgrade — instead of feeding your generator a model shape it doesn't expect. Ejected generators record it for you. The accepted range forms are `^1.2.0`, `~1.2.0`, `>=1.2.0`, and an exact `1.2.0`; anything else is rejected as unreadable rather than guessed at. -Omitting `requiresGenerator` means "assume current" — convenient while you iterate, and worth setting before you share the generator. +Omitting `requiresGenerator` means "assume current", which is fine while you iterate. +Set it before the generator outlives the CLI it was written against — a shared repository, a published package, anything regenerated by CI — since the failure it prevents (a changed model shape) otherwise shows up as strange output rather than an error. **A generator can declare its own options** with a JSON Schema, so publishers configure it the way they configure the built-ins: ```js export default defineGenerator({ name: 'permissions-matrix', + // The toolkit version this was written against. Declare it from the start: a generator + // usually outlives the CLI version it was written for, and without it a model change + // surfaces as odd output far from its cause. + requiresGenerator: '^1.2.0', options: { type: 'object', properties: { groupBy: { enum: ['tag', 'path'], default: 'tag' } }, diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index e0139eb235..4fa8014a28 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -65,6 +65,7 @@ Exit codes are a documented contract, and errors print one JSON object to stderr | 3 | validation error (zod co-selected) | | 4 | usage error (unknown command or flag, bad `--json`) | +The CLI uses top-level `await`, so the nearest `package.json` must set `"type": "module"` — otherwise `tsx` reports `Top-level await is currently not supported with the "cjs" output format`, which doesn't point at the fix. To ship it as a real bin, compile with `tsc` and point `package.json`'s `bin` at the compiled file. #### CLI reference docs @@ -315,6 +316,21 @@ await updateOrder({ orderId: 'ord_01khr…', body: { ...orderBody } }); An unknown top-level key in the grouped object (for example a leftover flat-style `{ limit: 10 }` instead of `{ params: { limit: 10 } }`) fails the call with a `TypeError` naming the key. TypeScript catches this at compile time; the runtime check covers transpilers that skip type-checking, so a mis-shaped call never silently drops data. +## Read-only properties + +A property marked `readOnly: true` is server-managed, so the generated request body type leaves it out: a body that references a named schema becomes `Omit`, and an inline object simply drops those properties. +Response types keep them. +The zod schemas and the mock factories read the same flag, so the type, the runtime validation, and the fixtures agree. + +Where `readOnly` sits matters, and it follows the specification version: + +- **OpenAPI 3.1** uses JSON Schema 2020-12, where `$ref` is an ordinary keyword. + Keywords beside a `$ref` take effect, so `{ $ref: './Entitlements.yaml', readOnly: true }` marks the property read-only. +- **OpenAPI 3.0 and 2.0** predate that: a `$ref` replaces the whole schema object, so a sibling `readOnly` has no meaning and is ignored. + Generation warns when it finds one, naming the property, because the intent is usually clear and silence would leave the property in every request body. + The [`spec-ref-siblings`](../rules/oas/spec-ref-siblings.md) rule flags the same thing when you lint. + To mark a referenced property read-only in 3.0, inline the schema or wrap the `$ref` in an `allOf`. + ## Error handling By default (`--error-mode throw`) an operation throws `ApiError` on any non-2xx response and returns the success body directly. 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 2d07782719..e5f4b65df3 100644 --- a/packages/client-generator/src/intermediate-representation/__tests__/build.test.ts +++ b/packages/client-generator/src/intermediate-representation/__tests__/build.test.ts @@ -1845,6 +1845,75 @@ describe('buildApiModel — request body readOnly stripping', () => { }); }); + // OpenAPI 3.1 is JSON Schema 2020-12: `$ref` is an ordinary keyword, so keywords + // beside it take effect (this repo's own `spec-ref-siblings` rule says as much). + // Dropping them left server-computed properties in every request body. + it('applies a readOnly sibling of a $ref in OpenAPI 3.1', () => { + const op = buildOpOnly({ + openapi: '3.1.0', + components: { + schemas: { + Computed: { type: 'object', properties: { tier: { type: 'string' } } }, + Widget: { + type: 'object', + required: ['name', 'refComputed'], + properties: { + name: { type: 'string' }, + refComputed: { $ref: '#/components/schemas/Computed', readOnly: true }, + }, + }, + }, + } as never, + paths: { + '/widgets': { + post: { + operationId: 'createWidget', + requestBody: { + required: true, + content: { + 'application/json': { schema: { $ref: '#/components/schemas/Widget' } }, + }, + }, + responses: { '201': { description: 'ok' } }, + }, + }, + }, + } as Partial); + expect(op.requestBody?.schema).toEqual({ + kind: 'omit', + base: 'Widget', + keys: ['refComputed'], + }); + }); + + it('ignores a readOnly sibling in OpenAPI 3.0, where a $ref replaces the schema, and says so', () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + try { + const op = postBody( + { + Computed: { type: 'object', properties: { tier: { type: 'string' } } }, + Widget: { + type: 'object', + required: ['name', 'refComputed'], + properties: { + name: { type: 'string' }, + refComputed: { $ref: '#/components/schemas/Computed', readOnly: true }, + }, + }, + }, + { $ref: '#/components/schemas/Widget' } + ); + // 3.0 semantics: the sibling has no meaning, so the property stays sendable… + expect(op.requestBody?.schema).toEqual({ kind: 'ref', name: 'Widget' }); + // …but the intent is obvious enough that silence would be the wrong answer. + const messages = warn.mock.calls.map(([message]) => message).join(''); + expect(messages).toContain('refComputed'); + expect(messages).toContain('readOnly'); + } finally { + warn.mockRestore(); + } + }); + it('collects readOnly keys through allOf members (deduped)', () => { const op = postBody( { diff --git a/packages/client-generator/src/intermediate-representation/build.ts b/packages/client-generator/src/intermediate-representation/build.ts index 7c97f459f0..52f266ca22 100644 --- a/packages/client-generator/src/intermediate-representation/build.ts +++ b/packages/client-generator/src/intermediate-representation/build.ts @@ -985,6 +985,17 @@ function scalarForEnumValues(values: unknown[], location: string): ScalarKind { return 'string'; } +/** + * Whether keywords beside a `$ref` apply. OpenAPI 3.1 is JSON Schema 2020-12, where + * `$ref` is an ordinary keyword and its siblings take effect; 3.0 and 2.0 predate that + * and a `$ref` replaces the whole schema object, so siblings mean nothing (the + * `spec-ref-siblings` lint rule reports them). Swagger 2 arrives here normalized to + * `3.0.3`, so it takes the 3.0 path. + */ +function refSiblingsApply(doc: Oas3Definition): boolean { + return !(doc.openapi ?? '').startsWith('3.0'); +} + function buildProperties( schema: Oas3Schema, location: string, @@ -992,8 +1003,18 @@ function buildProperties( ): PropertyModel[] { const props = schema.properties ?? {}; const required = new Set(schema.required ?? []); + const siblingsApply = refSiblingsApply(doc); return Object.entries(props).map(([name, sub]) => { - const readOnly = !isRef(sub) && (sub as { readOnly?: boolean }).readOnly === true; + const declared = (sub as { readOnly?: boolean }).readOnly === true; + // A `readOnly` sibling on a 3.0 `$ref` is a no-op the author almost certainly did + // not intend — it leaves a server-computed property in every request body — so it + // is reported rather than dropped in silence. + if (declared && isRef(sub) && !siblingsApply) { + logger.warn( + `generate-client: "${name}" declares readOnly beside a $ref, which OpenAPI ${doc.openapi} ignores — the property stays in request bodies. Inline the schema, wrap the $ref in allOf, or move the description to OpenAPI 3.1.\n` + ); + } + const readOnly = declared && (siblingsApply || !isRef(sub)); return { name, schema: schemaFromSlot(sub, `${location}.${name}`, doc), From ae4a9d02b4bb098e2d2006614ce84b45d9a48640 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sat, 8 Aug 2026 17:15:18 +0300 Subject: [PATCH 129/211] fix(cli): list only the credentials the description declares, and reject an unusable --token --- .changeset/cli-credential-flags.md | 5 +++ docs/@v2/guides/use-generated-client.md | 1 + .../src/emitters/runtime-sources.ts | 4 +- .../src/runtime/__tests__/cli.test.ts | 44 +++++++++++++++++++ packages/client-generator/src/runtime/cli.ts | 33 +++++++++++--- 5 files changed, 79 insertions(+), 8 deletions(-) create mode 100644 .changeset/cli-credential-flags.md diff --git a/.changeset/cli-credential-flags.md b/.changeset/cli-credential-flags.md new file mode 100644 index 0000000000..8b895b2f46 --- /dev/null +++ b/.changeset/cli-credential-flags.md @@ -0,0 +1,5 @@ +--- +'@redocly/client-generator': patch +--- + +Fixed the generated CLI advertising `--token` and the bearer environment variable on APIs that declare no bearer scheme, and silently discarding a token passed to them; the credential help now follows the description, and an unusable `--token` is a usage error naming the schemes the API accepts. diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index 4fa8014a28..0363348bf2 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -53,6 +53,7 @@ That keeps one name for the operation across everything you generate — the CLI Every global flag appears under `Global flags:` in the top-level help — `--server-url`, `--format json|ndjson`, `--dry-run`, `--page-all`, `--output`, `--token`, `--json` — together with the environment variables the CLI reads. Credentials come from environment variables derived from the file stem (constant-cased): bearer → `_TOKEN` (or `--token`), basic → `_USERNAME`/`_PASSWORD`, apiKey → `_API_KEY_`. +The help lists only what the description declares — an API with no bearer scheme shows no `--token` — and passing `--token` to such an API is a usage error (exit 4) naming the schemes it does accept, rather than a credential dropped in silence. `--server-url` overrides the baked server; `--dry-run` prints the prepared request (credentials redacted) without sending it; blob responses require `--output `; SSE operations stream events as one JSON object per line. Exit codes are a documented contract, and errors print one JSON object to stderr so stdout stays clean for piping: diff --git a/packages/client-generator/src/emitters/runtime-sources.ts b/packages/client-generator/src/emitters/runtime-sources.ts index 8fe5db4d3c..43ad76144b 100644 --- a/packages/client-generator/src/emitters/runtime-sources.ts +++ b/packages/client-generator/src/emitters/runtime-sources.ts @@ -25,7 +25,7 @@ export const RUNTIME_SOURCES = { 'paginate.ts': "import type { OperationArgs } from './create-client.js';\nimport type { PaginationSpec, QueryValue, RequestOptions } from './types.js';\n\n/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `params` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nexport function resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `params[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nexport async function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.params?.[spec.param];\n while (true) {\n const params = { ...args.params };\n if (cursor !== undefined) params[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, params }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `params[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.params?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call(\n { ...args, params: { ...args.params, [spec.param]: position } },\n init\n );\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nexport async function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\nexport type LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nexport function linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nexport async function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let params = args.params;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, params }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n params = { ...args.params, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nexport async function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n", 'cli.ts': - "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. */\n positionals: Array<{ name: string; description?: string }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n binName: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nexport function groupSlug(group: string): string {\n return group\n .trim()\n .replace(/[^A-Za-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .toLowerCase();\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nexport function envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist.\n const prefix = envPrefix(binName);\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ' --token Bearer token',\n ` --json Request body`,\n '',\n 'Environment:',\n ` ${prefix}_TOKEN, ${prefix}_USERNAME/${prefix}_PASSWORD, ${prefix}_API_KEY_`,\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: CliCommand[],\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(commands, wiring.binName, invocation.topic)) stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(invocation.command.schemas ?? {}, null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", + "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. */\n positionals: Array<{ name: string; description?: string }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n binName: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nexport function groupSlug(group: string): string {\n return group\n .trim()\n .replace(/[^A-Za-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .toLowerCase();\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nexport function envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const prefix = envPrefix(binName);\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: CliCommand[],\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(commands, wiring.binName, wiring.schemes ?? [], invocation.topic))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(invocation.command.schemas ?? {}, null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", } as const; /** Inline-embed variants: imports dropped, `export` stripped outside the kept surface. */ @@ -55,7 +55,7 @@ export const RUNTIME_SOURCES_STRIPPED = { 'paginate.ts': "/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `params` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nfunction resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `params[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nasync function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.params?.[spec.param];\n while (true) {\n const params = { ...args.params };\n if (cursor !== undefined) params[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, params }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `params[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.params?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call(\n { ...args, params: { ...args.params, [spec.param]: position } },\n init\n );\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nasync function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\ntype LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nfunction linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nasync function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let params = args.params;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, params }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n params = { ...args.params, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nasync function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}", 'cli.ts': - "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\ntype CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\ntype CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. */\n positionals: Array<{ name: string; description?: string }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\ntype CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\ntype CliWiring = {\n binName: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\ntype CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nfunction groupSlug(group: string): string {\n return group\n .trim()\n .replace(/[^A-Za-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .toLowerCase();\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nfunction parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nfunction envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist.\n const prefix = envPrefix(binName);\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ' --token Bearer token',\n ` --json Request body`,\n '',\n 'Environment:',\n ` ${prefix}_TOKEN, ${prefix}_USERNAME/${prefix}_PASSWORD, ${prefix}_API_KEY_`,\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nasync function runCli(\n commands: CliCommand[],\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(commands, wiring.binName, invocation.topic)) stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(invocation.command.schemas ?? {}, null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}", + "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\ntype CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\ntype CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. */\n positionals: Array<{ name: string; description?: string }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\ntype CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\ntype CliWiring = {\n binName: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\ntype CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nfunction groupSlug(group: string): string {\n return group\n .trim()\n .replace(/[^A-Za-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .toLowerCase();\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nfunction parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nfunction envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const prefix = envPrefix(binName);\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nasync function runCli(\n commands: CliCommand[],\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(commands, wiring.binName, wiring.schemes ?? [], invocation.topic))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(invocation.command.schemas ?? {}, null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}", } as const; export type RuntimeModuleName = keyof typeof RUNTIME_SOURCES; diff --git a/packages/client-generator/src/runtime/__tests__/cli.test.ts b/packages/client-generator/src/runtime/__tests__/cli.test.ts index 09b1625f3c..ea043218cd 100644 --- a/packages/client-generator/src/runtime/__tests__/cli.test.ts +++ b/packages/client-generator/src/runtime/__tests__/cli.test.ts @@ -157,6 +157,50 @@ function fakeWiring(overrides: Partial & { results?: Record { + const noBearer = [ + { key: 'BasicAuth', kind: 'basic' as const }, + { key: 'InternalToken', kind: 'apiKey' as const }, + ]; + + it('omits --token from help when the description declares no bearer scheme', async () => { + const { wiring, out } = fakeWiring({ schemes: noBearer }); + await runCli(COMMANDS, wiring, ['--help']); + const help = out.join('\n'); + expect(help).not.toContain('--token'); + // The environment block follows the same rule: only what this API can use. (The + // apiKey variable is named after its scheme, so match the bearer one exactly.) + expect(help).not.toContain('CAFE_TOKEN'); + expect(help).toContain('CAFE_USERNAME'); + expect(help).toContain('CAFE_API_KEY_INTERNAL_TOKEN'); + }); + + it('keeps --token when a bearer scheme is declared', async () => { + const { wiring, out } = fakeWiring(); + await runCli(COMMANDS, wiring, ['--help']); + expect(out.join('\n')).toContain('--token '); + }); + + it('rejects --token instead of silently discarding it, naming what the API accepts', async () => { + const { wiring, err } = fakeWiring({ schemes: noBearer }); + const code = await runCli(COMMANDS, wiring, [ + 'orders', + 'getOrder', + 'ord_1', + '--token', + 'secret', + ]); + // Exit 4 is the usage-error contract; a dropped credential reads as "my token is + // wrong" and costs a debugging session. + expect(code).toBe(4); + const message = JSON.parse(err.join('')).error.message; + expect(message).toContain('--token'); + expect(message).toContain('BasicAuth'); + expect(message).toContain('InternalToken'); + expect(message).not.toContain('secret'); + }); +}); + describe('runCli', () => { it('dispatches grouped args and pretty-prints the JSON result', async () => { const { wiring, calls, out } = fakeWiring({ results: { getOrder: { id: 'ord_1' } } }); diff --git a/packages/client-generator/src/runtime/cli.ts b/packages/client-generator/src/runtime/cli.ts index 628f189ed4..893d745a1e 100644 --- a/packages/client-generator/src/runtime/cli.ts +++ b/packages/client-generator/src/runtime/cli.ts @@ -279,6 +279,7 @@ function resolveAuth(wiring: CliWiring, token: string | undefined): Record scheme.kind)); + const credentials = [ + ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []), + ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []), + ...schemes + .filter((scheme) => scheme.kind === 'apiKey') + .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`), + ]; lines.push( '', 'Global flags:', @@ -348,11 +358,9 @@ function renderHelp( ' --dry-run Print the prepared request without sending it', ' --page-all Follow pagination, one JSON page per line', ' --output Write the response body to a file (required for binary)', - ' --token Bearer token', + ...(kinds.has('bearer') ? [' --token Bearer token'] : []), ` --json Request body`, - '', - 'Environment:', - ` ${prefix}_TOKEN, ${prefix}_USERNAME/${prefix}_PASSWORD, ${prefix}_API_KEY_`, + ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []), '', `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.` ); @@ -395,7 +403,8 @@ export async function runCli( const invocation = parseInvocation(commands, argv); if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message }); if (invocation.kind === 'help') { - for (const line of renderHelp(commands, wiring.binName, invocation.topic)) stdout(line); + for (const line of renderHelp(commands, wiring.binName, wiring.schemes ?? [], invocation.topic)) + stdout(line); return 0; } if (invocation.kind === 'schema') { @@ -404,6 +413,18 @@ export async function runCli( } const { command, positionals, params, globals } = invocation; + // A credential the user passed explicitly must never be dropped in silence: without a + // bearer scheme the request would go out unauthenticated and come back 401, which reads + // as "my token is wrong" rather than "that flag does nothing here". + const schemes = wiring.schemes ?? []; + if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) { + const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', '); + return fail(4, { + message: + `--token is a bearer credential, and this API declares no bearer scheme. ` + + (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`), + }); + } if (command.blob && globals.output === undefined) { return fail(4, { message: `${command.name} downloads a file: pass --output `, From e8df6a45d18cc483d10a82e84655864e76b2fb54 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sat, 8 Aug 2026 17:30:38 +0300 Subject: [PATCH 130/211] feat(cli): make schema the complete contract for a command --- .changeset/cli-credential-flags.md | 5 -- .changeset/ref-sibling-readonly.md | 6 --- docs/@v2/guides/use-generated-client.md | 5 +- packages/client-generator/src/emitters/cli.ts | 1 + .../src/emitters/runtime-sources.ts | 5 +- .../src/runtime/__tests__/cli.test.ts | 43 ++++++++++++++-- packages/client-generator/src/runtime/cli.ts | 51 +++++++++++++++++-- 7 files changed, 96 insertions(+), 20 deletions(-) delete mode 100644 .changeset/cli-credential-flags.md delete mode 100644 .changeset/ref-sibling-readonly.md diff --git a/.changeset/cli-credential-flags.md b/.changeset/cli-credential-flags.md deleted file mode 100644 index 8b895b2f46..0000000000 --- a/.changeset/cli-credential-flags.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'@redocly/client-generator': patch ---- - -Fixed the generated CLI advertising `--token` and the bearer environment variable on APIs that declare no bearer scheme, and silently discarding a token passed to them; the credential help now follows the description, and an unusable `--token` is a usage error naming the schemes the API accepts. diff --git a/.changeset/ref-sibling-readonly.md b/.changeset/ref-sibling-readonly.md deleted file mode 100644 index 261b593f1c..0000000000 --- a/.changeset/ref-sibling-readonly.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@redocly/client-generator': patch -'@redocly/cli': patch ---- - -Fixed `readOnly` being ignored when it sits beside a `$ref` in an OpenAPI 3.1 description, which left server-computed properties in generated request bodies; in 3.0, where a `$ref` replaces the schema, generation now warns instead of dropping the keyword silently. diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index 0363348bf2..dabfccc975 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -40,7 +40,7 @@ redocly generate-client openapi.yaml --output src/client.ts --generator sdk --ge npx tsx src/client.cli.ts orders listOrders --status open --limit 10 npx tsx src/client.cli.ts orders createOrder --json @order.json npx tsx src/client.cli.ts orders listOrders --page-all # one JSON page per line -npx tsx src/client.cli.ts schema createOrder # request/response schemas +npx tsx src/client.cli.ts schema createOrder # the operation's full contract ``` `--help` lists the commands, and for tagged APIs those are grouped: run ` --help` for one command's flags. @@ -66,6 +66,9 @@ Exit codes are a documented contract, and errors print one JSON object to stderr | 3 | validation error (zod co-selected) | | 4 | usage error (unknown command or flag, bad `--json`) | +`schema ` prints one operation's complete contract as JSON — method and path, the path and query parameters with their types and descriptions, whether a JSON body is accepted, the request and response schemas, and the flags that change how a call behaves (`paginated`, `sse`, `blob`). +It is the CLI's machine-readable surface: a script, a test harness, or an agent can discover the tool with `--help`, then read one `schema` call per command instead of parsing help text written for humans. + The CLI uses top-level `await`, so the nearest `package.json` must set `"type": "module"` — otherwise `tsx` reports `Top-level await is currently not supported with the "cjs" output format`, which doesn't point at the fix. To ship it as a real bin, compile with `tsc` and point `package.json`'s `bin` at the compiled file. diff --git a/packages/client-generator/src/emitters/cli.ts b/packages/client-generator/src/emitters/cli.ts index 0134d777bb..45ddd51726 100644 --- a/packages/client-generator/src/emitters/cli.ts +++ b/packages/client-generator/src/emitters/cli.ts @@ -77,6 +77,7 @@ export function commandData( path: op.path, positionals: op.pathParams.map((param) => ({ name: param.name, + type: flagFor(param).type, ...(param.description !== undefined ? { description: param.description } : {}), })), flags: op.queryParams.map(flagFor), diff --git a/packages/client-generator/src/emitters/runtime-sources.ts b/packages/client-generator/src/emitters/runtime-sources.ts index 43ad76144b..990934b765 100644 --- a/packages/client-generator/src/emitters/runtime-sources.ts +++ b/packages/client-generator/src/emitters/runtime-sources.ts @@ -25,7 +25,7 @@ export const RUNTIME_SOURCES = { 'paginate.ts': "import type { OperationArgs } from './create-client.js';\nimport type { PaginationSpec, QueryValue, RequestOptions } from './types.js';\n\n/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `params` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nexport function resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `params[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nexport async function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.params?.[spec.param];\n while (true) {\n const params = { ...args.params };\n if (cursor !== undefined) params[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, params }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `params[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.params?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call(\n { ...args, params: { ...args.params, [spec.param]: position } },\n init\n );\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nexport async function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\nexport type LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nexport function linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nexport async function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let params = args.params;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, params }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n params = { ...args.params, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nexport async function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n", 'cli.ts': - "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. */\n positionals: Array<{ name: string; description?: string }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n binName: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nexport function groupSlug(group: string): string {\n return group\n .trim()\n .replace(/[^A-Za-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .toLowerCase();\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nexport function envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const prefix = envPrefix(binName);\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: CliCommand[],\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(commands, wiring.binName, wiring.schemes ?? [], invocation.topic))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(invocation.command.schemas ?? {}, null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", + "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n binName: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nexport function groupSlug(group: string): string {\n return group\n .trim()\n .replace(/[^A-Za-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .toLowerCase();\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nexport function envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n method: command.method,\n path: command.path,\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const prefix = envPrefix(binName);\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: CliCommand[],\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(commands, wiring.binName, wiring.schemes ?? [], invocation.topic))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", } as const; /** Inline-embed variants: imports dropped, `export` stripped outside the kept surface. */ @@ -55,7 +55,7 @@ export const RUNTIME_SOURCES_STRIPPED = { 'paginate.ts': "/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `params` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nfunction resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `params[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nasync function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.params?.[spec.param];\n while (true) {\n const params = { ...args.params };\n if (cursor !== undefined) params[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, params }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `params[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.params?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call(\n { ...args, params: { ...args.params, [spec.param]: position } },\n init\n );\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nasync function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\ntype LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nfunction linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nasync function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let params = args.params;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, params }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n params = { ...args.params, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nasync function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}", 'cli.ts': - "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\ntype CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\ntype CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. */\n positionals: Array<{ name: string; description?: string }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\ntype CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\ntype CliWiring = {\n binName: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\ntype CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nfunction groupSlug(group: string): string {\n return group\n .trim()\n .replace(/[^A-Za-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .toLowerCase();\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nfunction parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nfunction envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const prefix = envPrefix(binName);\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nasync function runCli(\n commands: CliCommand[],\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(commands, wiring.binName, wiring.schemes ?? [], invocation.topic))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(invocation.command.schemas ?? {}, null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}", + "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\ntype CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\ntype CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\ntype CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\ntype CliWiring = {\n binName: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\ntype CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nfunction groupSlug(group: string): string {\n return group\n .trim()\n .replace(/[^A-Za-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .toLowerCase();\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nfunction parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nfunction envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n method: command.method,\n path: command.path,\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const prefix = envPrefix(binName);\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nasync function runCli(\n commands: CliCommand[],\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(commands, wiring.binName, wiring.schemes ?? [], invocation.topic))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}", } as const; export type RuntimeModuleName = keyof typeof RUNTIME_SOURCES; @@ -119,6 +119,7 @@ export const RUNTIME_DECLARED_NAMES = [ 'acceptFor', 'buildUrl', 'coerceResponseHeader', + 'commandContract', 'createClientCore', 'defaultRetryOn', 'encodeBase64', diff --git a/packages/client-generator/src/runtime/__tests__/cli.test.ts b/packages/client-generator/src/runtime/__tests__/cli.test.ts index ea043218cd..1b78569c69 100644 --- a/packages/client-generator/src/runtime/__tests__/cli.test.ts +++ b/packages/client-generator/src/runtime/__tests__/cli.test.ts @@ -19,7 +19,7 @@ const GET: CliCommand = { name: 'getOrder', method: 'GET', path: '/orders/{orderId}', - positionals: [{ name: 'orderId' }], + positionals: [{ name: 'orderId', type: 'string' }], flags: [], }; const CREATE: CliCommand = { @@ -157,6 +157,42 @@ function fakeWiring(overrides: Partial & { results?: Record { + it('reports parameters, body, schemas, and the behavior flags', async () => { + const { wiring, out } = fakeWiring(); + const code = await runCli(COMMANDS, wiring, ['schema', 'listOrders']); + expect(code).toBe(0); + const contract = JSON.parse(out.join('\n')); + + // An agent reading only this must be able to construct a valid invocation, so the + // parameters have to be here — 'GET' operations have nothing else. + expect(contract.operationId).toBe('listOrders'); + expect(contract.method).toBe('GET'); + expect(contract.path).toBe('/orders'); + expect(contract.parameters.query).toContainEqual( + expect.objectContaining({ name: 'status', param: 'status', type: 'string', required: false }) + ); + expect(contract.paginated).toBe(true); + }); + + it('reports a path parameter with its type, which the usage line already knows', async () => { + const { wiring, out } = fakeWiring(); + await runCli(COMMANDS, wiring, ['schema', 'getOrder']); + const contract = JSON.parse(out.join('\n')); + expect(contract.parameters.path).toEqual([ + expect.objectContaining({ name: 'orderId', type: 'string', required: true }), + ]); + }); + + it('keeps the request and response schemas it already reported', async () => { + const { wiring, out } = fakeWiring(); + await runCli(COMMANDS, wiring, ['schema', 'createOrder']); + const contract = JSON.parse(out.join('\n')); + expect(contract.request).toBeDefined(); + expect(contract.body).toEqual({ required: true }); + }); +}); + describe('credential flags follow the declared schemes', () => { const noBearer = [ { key: 'BasicAuth', kind: 'basic' as const }, @@ -354,11 +390,12 @@ describe('runCli', () => { expect(JSON.parse(out.join('\n'))).toEqual({ saved: 'report.bin', bytes: 3 }); }); - it('schema prints the stored request/response schemas', async () => { + it('schema prints the stored request/response schemas inside the contract', async () => { const { wiring, out } = fakeWiring(); const code = await runCli(COMMANDS, wiring, ['schema', 'createOrder']); expect(code).toBe(0); - expect(JSON.parse(out.join('\n'))).toEqual({ request: { kind: 'object' } }); + // The schemas keep their own keys; the contract adds the rest around them. + expect(JSON.parse(out.join('\n'))).toMatchObject({ request: { kind: 'object' } }); }); it('help renders groups at the root, commands per group, and flags per command', async () => { diff --git a/packages/client-generator/src/runtime/cli.ts b/packages/client-generator/src/runtime/cli.ts index 893d745a1e..d65b479d87 100644 --- a/packages/client-generator/src/runtime/cli.ts +++ b/packages/client-generator/src/runtime/cli.ts @@ -25,8 +25,12 @@ export type CliCommand = { summary?: string; method: string; path: string; - /** Path params, in path-template order. */ - positionals: Array<{ name: string; description?: 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. */ body?: { required: boolean }; @@ -276,6 +280,47 @@ function resolveAuth(wiring: CliWiring, token: string | undefined): Record` prints. It has + * to carry the parameters: 'GET' operations have no body, so without them the output says + * nothing a caller could act on, and the only alternative is scraping `--help`, which is + * prose written for humans. + */ +function commandContract(command: CliCommand): Record { + return { + operationId: command.name, + ...(command.group === undefined ? {} : { group: groupSlug(command.group) }), + ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }), + method: command.method, + path: command.path, + parameters: { + path: command.positionals.map((positional) => ({ + name: positional.name, + type: positional.type ?? 'string', + required: true, + ...(positional.description === undefined + ? {} + : { description: oneLine(positional.description) }), + })), + // `name` is what you type (`--max-total`); `param` is the wire name it becomes. + query: command.flags.map((flag) => ({ + name: flag.name, + param: flag.param, + type: flag.type, + required: flag.required, + ...(flag.enum === undefined ? {} : { enum: flag.enum }), + ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }), + })), + }, + ...(command.body === undefined ? {} : { body: command.body }), + ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }), + ...(command.paginated === true ? { paginated: true } : {}), + ...(command.sse === true ? { sse: true } : {}), + ...(command.blob === true ? { blob: true } : {}), + ...(command.schemas ?? {}), + }; +} + function renderHelp( commands: CliCommand[], binName: string, @@ -408,7 +453,7 @@ export async function runCli( return 0; } if (invocation.kind === 'schema') { - stdout(JSON.stringify(invocation.command.schemas ?? {}, null, 2)); + stdout(JSON.stringify(commandContract(invocation.command), null, 2)); return 0; } From 597b938372c6fcb8f9b9c83286845ee69f0c2340 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 9 Aug 2026 22:40:25 +0300 Subject: [PATCH 131/211] =?UTF-8?q?feat(cli):=20composable=20generated=20C?= =?UTF-8?q?LIs=20=E2=80=94=20importable=20modules,=20custom=20commands,=20?= =?UTF-8?q?multi-API=20sources?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../skills/cli-generator/SKILL.md | 35 +++- .../src/emitters/__tests__/cli.test.ts | 13 +- packages/client-generator/src/emitters/cli.ts | 53 +++-- .../src/emitters/runtime-sources.ts | 13 +- .../src/generators/cli/AGENTS.md | 35 +++- packages/client-generator/src/index.ts | 10 +- .../src/runtime/__tests__/cli.test.ts | 157 ++++++++++++++- packages/client-generator/src/runtime/cli.ts | 188 +++++++++++++++++- tests/e2e/generate-client/cli-compose.test.ts | 133 +++++++++++++ 9 files changed, 601 insertions(+), 36 deletions(-) create mode 100644 tests/e2e/generate-client/cli-compose.test.ts 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 64cde6c2f3..444a0363e3 100644 --- a/packages/client-generator/eject-assets/skills/cli-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/cli-generator/SKILL.md @@ -34,9 +34,11 @@ with `--help`, a `schema ` introspection command, and `--dry-run`. dots and other non-word characters folded to `-` (`openapi.client` → `openapi-client`), because the stem follows the TypeScript file convention and a usage line reading `openapi.client orders …` looks like a path. `client.binName` overrides it. -- **Credentials come from the environment** (a stem-derived prefix, e.g. - `CLIENT_TOKEN`) or explicit flags; `--dry-run` prints the prepared request with - credentials REDACTED. +- **Credentials come from the environment** — a prefix derived from the bin name + (`CLIENT_TOKEN`), overridable via `wiring.envPrefix` — or explicit flags; `--dry-run` + prints the prepared request with credentials REDACTED. Help lists only the credentials + the description declares, and an unusable `--token` is a usage error, never silently + dropped. - **Validation is on by default.** The generator declares `requires: ['sdk', 'zod']` and the pipeline pulls prerequisites in automatically, so `--generator cli` alone produces a validating CLI — a user shouldn't have to know which other generator provides it. The @@ -46,6 +48,33 @@ with `--help`, a `schema ` introspection command, and `--dry-run`. modules it imports (the sdk and the zod module). Anything emitted must be erasable TypeScript; a parameter property anywhere in that import graph breaks the zero-build runner. +- **The generated module is a library as well as a binary.** It exports `COMMANDS`, + `wiring`, and `run`, and self-executes only when it is the process entry — a REALPATH + comparison of `import.meta.url` against `argv[1]`, because some runners resolve + symlinks in one but not the other (macOS temp dirs, installed bin symlinks), and a + plain URL comparison silently runs nothing. `import.meta.main` would be cleaner but is + absent from our Node floors. Importing the module must be side-effect-safe: + module-level wiring (zod validation) touches only the module's OWN client, never a + global. +- **Behavior that is not in the description is composed, never generated.** A custom + command (`login`, anything) is the operation-command data shape plus a `handler`, so it + inherits help, parsing, `schema`, and the exit-code contract; `runCli` dispatches it + instead of the client. The generator itself never learns what such a command does — + credentials files, login flows, and profiles are user land (or a future satellite), + by design. +- **One binary can span several descriptions.** `runCli` also accepts sources — each a + command list plus its OWN wiring (own base URL, schemes, credentials) behind a + namespace, so colliding operationIds across descriptions are simply different commands + (`cafe shop createOrder`, `cafe kitchen createOrder`). A namespace-less source puts + commands at the root (`cafe login`); a root command whose name matches a namespace is + rejected at startup, never shadowed. +- **The composed entry is generated, not hand-rolled.** A top-level `client.cliOutput` + makes `redocly generate-client` (no api argument) emit one entry over every api that + selected `cli`: the namespace is the api ALIAS from `apis:`, and the credential prefix + defaults to `_` (`CAFE_SHOP_TOKEN`) via `wiring.envPrefix` — which + exists precisely so the display name and the credential prefix can differ. The composed + entry exports its `SOURCES` so an adopter layers custom commands around it without + editing a generated file. Without `cliOutput`, nothing changes. ## Emitters that implement it diff --git a/packages/client-generator/src/emitters/__tests__/cli.test.ts b/packages/client-generator/src/emitters/__tests__/cli.test.ts index 20dff3e78e..7ed36f020d 100644 --- a/packages/client-generator/src/emitters/__tests__/cli.test.ts +++ b/packages/client-generator/src/emitters/__tests__/cli.test.ts @@ -231,13 +231,22 @@ describe('renderCliModule', () => { expect(out).toContain('function parseInvocation'); // embedded runtime expect(out).toContain('import { client, configure } from "./client.js";'); expect(out).toContain('schemes: [{"key":"BearerAuth","kind":"bearer"}]'); - expect(out).toContain('await runCli(COMMANDS'); + // A library as well as a binary: the exports composition imports, and an entry + // guard so importing the module never executes the CLI. + expect(out).toContain('export const COMMANDS: CliCommand[]'); + expect(out).toContain('export const wiring: CliWiring'); + expect(out).toContain('export const run ='); + expect(out).toContain( + 'realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1])' + ); expect(out).not.toContain('from "@redocly/client-generator"'); }); it('package mode imports runCli from the package; zod co-selection wires validation', () => { const out = renderCliModule(MODEL, { ...options, runtime: 'package', zodSelected: true }); - expect(out).toContain('import { runCli, type CliCommand } from "@redocly/client-generator";'); + expect(out).toContain( + 'import { runCli, type CliCommand, type CliWiring } from "@redocly/client-generator";' + ); expect(out).not.toContain('function parseInvocation'); expect(out).toContain('import { zodValidation } from "./client.zod.js";'); expect(out).toContain( diff --git a/packages/client-generator/src/emitters/cli.ts b/packages/client-generator/src/emitters/cli.ts index 45ddd51726..bed1e9a994 100644 --- a/packages/client-generator/src/emitters/cli.ts +++ b/packages/client-generator/src/emitters/cli.ts @@ -142,10 +142,10 @@ export function renderCliModule(model: ApiModel, options: CliModuleOptions): str const parts = [ '#!/usr/bin/env node', HEADER, - 'import { readFileSync, writeFileSync } from "node:fs";', + 'import { readFileSync, realpathSync, writeFileSync } from "node:fs";\nimport { fileURLToPath } from "node:url";', [ ...(options.runtime === 'package' - ? ['import { runCli, type CliCommand } from "@redocly/client-generator";'] + ? ['import { runCli, type CliCommand, type CliWiring } from "@redocly/client-generator";'] : []), `import { ${clientImports.join(', ')} } from "${clientModule}";`, ...(options.zodSelected @@ -155,7 +155,7 @@ export function renderCliModule(model: ApiModel, options: CliModuleOptions): str ...(options.runtime === 'inline' ? ['// ─── Embedded cli engine (@redocly/client-generator) ───\n' + embedCliRuntime()] : []), - `const COMMANDS: CliCommand[] = ${codeJson(commands, 2)};`, + `export const COMMANDS: CliCommand[] = ${codeJson(commands, 2)};`, ...(options.zodSelected ? [ // A dry run never sends the request, so its "response" is the stub the dry-run @@ -164,20 +164,39 @@ export function renderCliModule(model: ApiModel, options: CliModuleOptions): str `use(zodValidation(process.argv.includes("--dry-run") ? { response: false } : {}));`, ] : []), - `process.exit( - await runCli(COMMANDS, { - binName: ${codeJson(options.binName)}, - client, - configure, - schemes: ${codeJson(schemes)}, - env: process.env, - stdin: () => readFileSync(0, "utf-8"), - readFile: (path: string) => readFileSync(path, "utf-8"), - writeFile: (path: string, data: Uint8Array) => writeFileSync(path, data), - stdout: (line: string) => console.log(line), - stderr: (line: string) => console.error(line), - }, process.argv.slice(2)) -);`, + `export const wiring: CliWiring = { + binName: ${codeJson(options.binName)}, + client, + configure, + schemes: ${codeJson(schemes)}, + env: process.env, + stdin: () => readFileSync(0, "utf-8"), + readFile: (path: string) => readFileSync(path, "utf-8"), + writeFile: (path: string, data: Uint8Array) => writeFileSync(path, data), + stdout: (line: string) => console.log(line), + stderr: (line: string) => console.error(line), +}; + +/** Run this CLI programmatically; defaults to the process argv. */ +export const run = (argv: string[] = process.argv.slice(2)): Promise => + runCli(COMMANDS, wiring, argv); + +// Self-execute only as the process entry, so importing this module is side-effect-safe: +// composed binaries and login-style wrappers import COMMANDS/wiring/run instead of +// editing this generated file. Both sides are realpath-resolved — some runners resolve +// symlinks in import.meta.url but not argv[1] (macOS temp dirs, installed bins); the +// catch covers an entry that is not a file at all (REPL, node -e). +function isProcessEntry(): boolean { + if (process.argv[1] === undefined) return false; + try { + return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]); + } catch { + return false; + } +} +if (isProcessEntry()) { + process.exit(await run()); +}`, ]; return parts.join('\n\n') + '\n'; } diff --git a/packages/client-generator/src/emitters/runtime-sources.ts b/packages/client-generator/src/emitters/runtime-sources.ts index 990934b765..2ffe48d859 100644 --- a/packages/client-generator/src/emitters/runtime-sources.ts +++ b/packages/client-generator/src/emitters/runtime-sources.ts @@ -25,7 +25,7 @@ export const RUNTIME_SOURCES = { 'paginate.ts': "import type { OperationArgs } from './create-client.js';\nimport type { PaginationSpec, QueryValue, RequestOptions } from './types.js';\n\n/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `params` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nexport function resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `params[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nexport async function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.params?.[spec.param];\n while (true) {\n const params = { ...args.params };\n if (cursor !== undefined) params[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, params }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `params[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.params?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call(\n { ...args, params: { ...args.params, [spec.param]: position } },\n init\n );\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nexport async function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\nexport type LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nexport function linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nexport async function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let params = args.params;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, params }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n params = { ...args.params, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nexport async function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n", 'cli.ts': - "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n binName: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nexport function groupSlug(group: string): string {\n return group\n .trim()\n .replace(/[^A-Za-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .toLowerCase();\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nexport function envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n method: command.method,\n path: command.path,\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const prefix = envPrefix(binName);\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: CliCommand[],\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(commands, wiring.binName, wiring.schemes ?? [], invocation.topic))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", + "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n binName: string;\n /** Credential variable prefix. Defaults to `binName`, constant-cased — set it when the\n * displayed name and the credential family must differ (a composed multi-API binary). */\n envPrefix?: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\nexport type CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\nexport type CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\nexport type CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\nexport type CommandSource = {\n namespace?: string;\n commands: Array;\n wiring: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nexport function groupSlug(group: string): string {\n return group\n .trim()\n .replace(/[^A-Za-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .toLowerCase();\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nexport function envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nexport async function runCli(sources: CommandSource[], argv: string[]): Promise;\nexport async function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = sources[0].wiring;\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.binName)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, source.wiring, argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, (root as CommandSource).wiring, argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], binName: string): string[] {\n const lines = [`Usage: ${binName} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${binName} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n for (const line of renderHelp(\n commands,\n wiring.binName,\n wiring.schemes ?? [],\n prefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", } as const; /** Inline-embed variants: imports dropped, `export` stripped outside the kept surface. */ @@ -55,7 +55,7 @@ export const RUNTIME_SOURCES_STRIPPED = { 'paginate.ts': "/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `params` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nfunction resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `params[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nasync function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.params?.[spec.param];\n while (true) {\n const params = { ...args.params };\n if (cursor !== undefined) params[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, params }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `params[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.params?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call(\n { ...args, params: { ...args.params, [spec.param]: position } },\n init\n );\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nasync function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\ntype LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nfunction linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nasync function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let params = args.params;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, params }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n params = { ...args.params, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nasync function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}", 'cli.ts': - "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\ntype CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\ntype CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\ntype CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\ntype CliWiring = {\n binName: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\ntype CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nfunction groupSlug(group: string): string {\n return group\n .trim()\n .replace(/[^A-Za-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .toLowerCase();\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nfunction parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nfunction envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n method: command.method,\n path: command.path,\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const prefix = envPrefix(binName);\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nasync function runCli(\n commands: CliCommand[],\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(commands, wiring.binName, wiring.schemes ?? [], invocation.topic))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}", + "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\ntype CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\ntype CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\ntype CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\ntype CliWiring = {\n binName: string;\n /** Credential variable prefix. Defaults to `binName`, constant-cased — set it when the\n * displayed name and the credential family must differ (a composed multi-API binary). */\n envPrefix?: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\ntype CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\ntype CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\ntype CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\ntype CommandSource = {\n namespace?: string;\n commands: Array;\n wiring: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nfunction groupSlug(group: string): string {\n return group\n .trim()\n .replace(/[^A-Za-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .toLowerCase();\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nfunction parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nfunction envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nasync function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nasync function runCli(sources: CommandSource[], argv: string[]): Promise;\nasync function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = sources[0].wiring;\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.binName)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, source.wiring, argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, (root as CommandSource).wiring, argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], binName: string): string[] {\n const lines = [`Usage: ${binName} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${binName} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n for (const line of renderHelp(\n commands,\n wiring.binName,\n wiring.schemes ?? [],\n prefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}", } as const; export type RuntimeModuleName = keyof typeof RUNTIME_SOURCES; @@ -77,6 +77,9 @@ export const RUNTIME_DECLARED_NAMES = [ 'Client', 'ClientConfig', 'ClientCore', + 'CommandContext', + 'CommandSource', + 'CustomCommand', 'Envelope', 'EnvelopeResult', 'EnvelopeResultForKnownInit', @@ -101,6 +104,7 @@ export const RUNTIME_DECLARED_NAMES = [ 'QueryValue', 'RequestContext', 'RequestOptions', + 'ResolvedCommand', 'ResponseHeaderSpec', 'Result', 'RetryConfig', @@ -136,6 +140,7 @@ export const RUNTIME_DECLARED_NAMES = [ 'loadBody', 'mergeSetup', 'middlewareChain', + 'normalizeCommands', 'oneLine', 'pageCall', 'pages', @@ -149,13 +154,17 @@ export const RUNTIME_DECLARED_NAMES = [ 'readEnvelopeHeaders', 'readError', 'redactHeaders', + 'renderComposedHelp', 'renderHelp', 'resolveAuth', 'resolvePointer', 'resolveToken', 'retryDelay', 'runCli', + 'runSingle', + 'runSources', 'send', + 'shadowedCommandName', 'sleep', 'splitArgs', 'sse', diff --git a/packages/client-generator/src/generators/cli/AGENTS.md b/packages/client-generator/src/generators/cli/AGENTS.md index 46ab9d5baf..9bf2a3daec 100644 --- a/packages/client-generator/src/generators/cli/AGENTS.md +++ b/packages/client-generator/src/generators/cli/AGENTS.md @@ -28,9 +28,11 @@ with `--help`, a `schema ` introspection command, and `--dry-run`. dots and other non-word characters folded to `-` (`openapi.client` → `openapi-client`), because the stem follows the TypeScript file convention and a usage line reading `openapi.client orders …` looks like a path. `client.binName` overrides it. -- **Credentials come from the environment** (a stem-derived prefix, e.g. - `CLIENT_TOKEN`) or explicit flags; `--dry-run` prints the prepared request with - credentials REDACTED. +- **Credentials come from the environment** — a prefix derived from the bin name + (`CLIENT_TOKEN`), overridable via `wiring.envPrefix` — or explicit flags; `--dry-run` + prints the prepared request with credentials REDACTED. Help lists only the credentials + the description declares, and an unusable `--token` is a usage error, never silently + dropped. - **Validation is on by default.** The generator declares `requires: ['sdk', 'zod']` and the pipeline pulls prerequisites in automatically, so `--generator cli` alone produces a validating CLI — a user shouldn't have to know which other generator provides it. The @@ -40,6 +42,33 @@ with `--help`, a `schema ` introspection command, and `--dry-run`. modules it imports (the sdk and the zod module). Anything emitted must be erasable TypeScript; a parameter property anywhere in that import graph breaks the zero-build runner. +- **The generated module is a library as well as a binary.** It exports `COMMANDS`, + `wiring`, and `run`, and self-executes only when it is the process entry — a REALPATH + comparison of `import.meta.url` against `argv[1]`, because some runners resolve + symlinks in one but not the other (macOS temp dirs, installed bin symlinks), and a + plain URL comparison silently runs nothing. `import.meta.main` would be cleaner but is + absent from our Node floors. Importing the module must be side-effect-safe: + module-level wiring (zod validation) touches only the module's OWN client, never a + global. +- **Behavior that is not in the description is composed, never generated.** A custom + command (`login`, anything) is the operation-command data shape plus a `handler`, so it + inherits help, parsing, `schema`, and the exit-code contract; `runCli` dispatches it + instead of the client. The generator itself never learns what such a command does — + credentials files, login flows, and profiles are user land (or a future satellite), + by design. +- **One binary can span several descriptions.** `runCli` also accepts sources — each a + command list plus its OWN wiring (own base URL, schemes, credentials) behind a + namespace, so colliding operationIds across descriptions are simply different commands + (`cafe shop createOrder`, `cafe kitchen createOrder`). A namespace-less source puts + commands at the root (`cafe login`); a root command whose name matches a namespace is + rejected at startup, never shadowed. +- **The composed entry is generated, not hand-rolled.** A top-level `client.cliOutput` + makes `redocly generate-client` (no api argument) emit one entry over every api that + selected `cli`: the namespace is the api ALIAS from `apis:`, and the credential prefix + defaults to `_` (`CAFE_SHOP_TOKEN`) via `wiring.envPrefix` — which + exists precisely so the display name and the credential prefix can differ. The composed + entry exports its `SOURCES` so an adopter layers custom commands around it without + editing a generated file. Without `cliOutput`, nothing changes. ## Emitters that implement it diff --git a/packages/client-generator/src/index.ts b/packages/client-generator/src/index.ts index c59de736e3..578597d861 100644 --- a/packages/client-generator/src/index.ts +++ b/packages/client-generator/src/index.ts @@ -52,7 +52,15 @@ export type { } from './runtime/index.js'; // The generated-CLI engine (package-mode cli files import it from the package root). export { runCli } from './runtime/cli.js'; -export type { CliAuthScheme, CliCommand, CliWiring } from './runtime/cli.js'; +export type { + CliAuthScheme, + CliCommand, + CliGlobals, + CliWiring, + CommandContext, + CommandSource, + CustomCommand, +} from './runtime/cli.js'; // The user-facing pagination rule shapes (`Config.pagination` / `x-redoclyPagination`). export type { PaginationConfig, PaginationRule, PaginationStyle } from './emitters/pagination.js'; export type { diff --git a/packages/client-generator/src/runtime/__tests__/cli.test.ts b/packages/client-generator/src/runtime/__tests__/cli.test.ts index 1b78569c69..f83c4bcf4e 100644 --- a/packages/client-generator/src/runtime/__tests__/cli.test.ts +++ b/packages/client-generator/src/runtime/__tests__/cli.test.ts @@ -1,4 +1,11 @@ -import { parseInvocation, runCli, type CliCommand, type CliWiring } from '../cli.js'; +import { + parseInvocation, + runCli, + type CliCommand, + type CliWiring, + type CommandContext, + type CustomCommand, +} from '../cli.js'; const LIST: CliCommand = { group: 'orders', @@ -157,6 +164,154 @@ function fakeWiring(overrides: Partial & { results?: Record { + const whoami = (received: CommandContext[]): CustomCommand => ({ + name: 'whoami', + summary: 'Print the current identity.', + flags: [{ name: 'verbose', param: 'verbose', type: 'boolean', required: false }], + handler: (context) => { + received.push(context); + context.wiring.stdout('me'); + return 0; + }, + }); + + it('dispatches a handler with parsed inputs and the wiring, and uses its exit code', async () => { + const received: CommandContext[] = []; + const { wiring, out } = fakeWiring(); + const code = await runCli([...COMMANDS, whoami(received)], wiring, ['whoami', '--verbose']); + expect(code).toBe(0); + expect(out).toEqual(['me']); + expect(received[0].params).toEqual({ verbose: true }); + expect(received[0].wiring.binName).toBe('cafe'); + }); + + it('lists a custom command in help and its declared contract in schema', async () => { + const { wiring, out } = fakeWiring(); + await runCli([...COMMANDS, whoami([])], wiring, ['--help']); + expect(out.join('\n')).toContain('whoami Print the current identity.'); + + const schema = fakeWiring(); + await runCli([...COMMANDS, whoami([])], schema.wiring, ['schema', 'whoami']); + const contract = JSON.parse(schema.out.join('\n')); + expect(contract.operationId).toBe('whoami'); + expect(contract.parameters.query).toEqual([ + expect.objectContaining({ name: 'verbose', type: 'boolean' }), + ]); + expect(contract.request).toBeUndefined(); + }); + + it('a thrown handler exits 1 with the standard error JSON', async () => { + const boom: CustomCommand = { + name: 'boom', + handler: () => { + throw new Error('handler exploded'); + }, + }; + const { wiring, err } = fakeWiring(); + const code = await runCli([...COMMANDS, boom], wiring, ['boom']); + expect(code).toBe(1); + expect(JSON.parse(err.join('')).error.message).toContain('handler exploded'); + }); + + it('rejects a custom command whose name collides with a generated one', async () => { + const shadow: CustomCommand = { name: 'ping', handler: () => 0 }; + const { wiring, err } = fakeWiring(); + const code = await runCli([...COMMANDS, shadow], wiring, ['ping']); + // Silently shadowing an operation is how an operator debugs the wrong thing. + expect(code).toBe(4); + expect(JSON.parse(err.join('')).error.message).toContain('ping'); + }); +}); + +describe('multi-source runCli (one binary, several APIs)', () => { + function sources(overrides: { rootCommands?: CustomCommand[] } = {}) { + const main = fakeWiring(); + const syncer = fakeWiring({ envPrefix: 'REUNITE_SYNCER' }); + const root = fakeWiring(); + return { + main, + syncer, + root, + list: [ + ...(overrides.rootCommands + ? [{ commands: overrides.rootCommands, wiring: root.wiring }] + : []), + { namespace: 'main', commands: COMMANDS, wiring: main.wiring }, + // The same operationIds again: collisions across descriptions are the normal case. + { namespace: 'syncer', commands: COMMANDS, wiring: syncer.wiring }, + ], + }; + } + + it('routes the first token to its source, so colliding operationIds are different commands', async () => { + const context = sources(); + const code = await runCli(context.list, ['syncer', 'orders', 'getOrder', 'ord_9']); + expect(code).toBe(0); + expect(context.syncer.calls).toEqual([{ name: 'getOrder', variables: { orderId: 'ord_9' } }]); + expect(context.main.calls).toEqual([]); + }); + + it('a namespace-less source puts its commands at the root', async () => { + const login: CustomCommand = { + name: 'login', + handler: (context) => { + context.wiring.stdout('logged in'); + return 0; + }, + }; + const context = sources({ rootCommands: [login] }); + const code = await runCli(context.list, ['login']); + expect(code).toBe(0); + expect(context.root.out).toEqual(['logged in']); + }); + + it('top-level help lists the namespaces; namespace help lists that API alone', async () => { + const context = sources(); + await runCli(context.list, ['--help']); + const help = context.main.out.join('\n'); + expect(help).toContain('main'); + expect(help).toContain('syncer'); + + const scoped = sources(); + await runCli(scoped.list, ['syncer', '--help']); + expect(scoped.syncer.out.join('\n')).toContain('orders'); + }); + + it('an unknown first token is a usage error naming the namespaces', async () => { + const context = sources(); + const code = await runCli(context.list, ['nowhere', 'getOrder']); + expect(code).toBe(4); + const message = JSON.parse(context.main.err.join('')).error.message; + expect(message).toContain('main'); + expect(message).toContain('syncer'); + }); +}); + +describe('wiring.envPrefix', () => { + it('overrides the credential prefix without changing the displayed name', async () => { + const { wiring, out } = fakeWiring({ envPrefix: 'REUNITE_MAIN' }); + await runCli(COMMANDS, wiring, ['--help']); + const help = out.join('\n'); + expect(help).toContain('Usage: cafe'); + expect(help).toContain('REUNITE_MAIN_TOKEN'); + expect(help).not.toContain('CAFE_TOKEN'); + }); + + it('reads credentials under the override', async () => { + const { wiring, calls, configured } = fakeWiring({ + envPrefix: 'REUNITE_MAIN', + env: { REUNITE_MAIN_TOKEN: 'tok' }, + results: { getOrder: {} }, + }); + await runCli(COMMANDS, wiring, ['orders', 'getOrder', 'ord_1']); + expect(calls).toHaveLength(1); + expect( + configured.some((config) => (config.auth as { bearer?: string })?.bearer === 'tok') + ).toBe(true); + }); +}); + describe('schema is the complete contract for one command', () => { it('reports parameters, body, schemas, and the behavior flags', async () => { const { wiring, out } = fakeWiring(); diff --git a/packages/client-generator/src/runtime/cli.ts b/packages/client-generator/src/runtime/cli.ts index d65b479d87..f28cfb5008 100644 --- a/packages/client-generator/src/runtime/cli.ts +++ b/packages/client-generator/src/runtime/cli.ts @@ -51,6 +51,9 @@ export type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' } export type CliWiring = { binName: string; + /** Credential variable prefix. Defaults to `binName`, constant-cased — set it when the + * displayed name and the credential family must differ (a composed multi-API binary). */ + envPrefix?: string; /** The generated instance client (grouped-args methods). */ client: Record; configure: (config: Record) => void; @@ -64,7 +67,7 @@ export type CliWiring = { stderr: (line: string) => void; }; -type CliGlobals = { +export type CliGlobals = { serverUrl?: string; format?: 'json' | 'ndjson'; dryRun?: boolean; @@ -86,6 +89,66 @@ 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 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; + wiring: CliWiring; +}; + +type ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] }; + +/** Custom commands as the command shape the parser reads; generated ones pass through. */ +function normalizeCommands(commands: Array): ResolvedCommand[] { + return commands.map((command) => + 'handler' in command + ? { method: '', path: '', positionals: [], flags: [], ...command } + : command + ); +} + +/** + * The name of a custom command that shadows another command. Rejected at startup: an + * operator typing an operationId must never silently run something else. + */ +function shadowedCommandName(commands: ResolvedCommand[]): string | undefined { + const seen = new Map(); + for (const command of commands) { + const key = `${command.group ?? ''}\u0000${command.name}`; + seen.set(key, [...(seen.get(key) ?? []), command]); + } + for (const clashing of seen.values()) { + if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) { + return clashing[0].name; + } + } + return undefined; +} + const GLOBAL_FLAGS: Record = { 'server-url': { key: 'serverUrl' }, format: { key: 'format' }, @@ -257,7 +320,7 @@ export function envPrefix(binName: string): string { function resolveAuth(wiring: CliWiring, token: string | undefined): Record { const env = wiring.env ?? {}; - const prefix = envPrefix(wiring.binName); + const prefix = wiring.envPrefix ?? envPrefix(wiring.binName); const auth: Record = {}; for (const scheme of wiring.schemes ?? []) { if (scheme.kind === 'bearer') { @@ -291,8 +354,8 @@ function commandContract(command: CliCommand): Record { operationId: command.name, ...(command.group === undefined ? {} : { group: groupSlug(command.group) }), ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }), - method: command.method, - path: command.path, + ...(command.method === '' ? {} : { method: command.method }), + ...(command.path === '' ? {} : { path: command.path }), parameters: { path: command.positionals.map((positional) => ({ name: positional.name, @@ -325,6 +388,7 @@ function renderHelp( commands: CliCommand[], binName: string, schemes: CliAuthScheme[], + prefix: string, topic?: CliCommand | string ): string[] { if (topic !== undefined && typeof topic !== 'string') { @@ -386,7 +450,6 @@ function renderHelp( // Flags that apply to every command, and the env vars credentials come from: a flag // absent from --help may as well not exist — and one this API cannot use should not be // listed at all, since the operator would spend the debugging session on their token. - const prefix = envPrefix(binName); const kinds = new Set(schemes.map((scheme) => scheme.kind)); const credentials = [ ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []), @@ -435,11 +498,111 @@ function redactHeaders(headers: Record, secrets: string[]): Reco /** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */ export async function runCli( - commands: CliCommand[], + commands: Array, + wiring: CliWiring, + argv: string[] +): Promise; +/** The composed form: one binary over several sources, each namespaced with its own wiring. */ +export async function runCli(sources: CommandSource[], argv: string[]): Promise; +export async function runCli( + commandsOrSources: Array | CommandSource[], + wiringOrArgv: CliWiring | string[], + argv?: string[] +): Promise { + if (Array.isArray(wiringOrArgv)) { + return runSources(commandsOrSources as CommandSource[], wiringOrArgv); + } + return runSingle( + commandsOrSources as Array, + wiringOrArgv, + argv ?? [] + ); +} + +/** Route the first token to its source; the namespace-less source owns the root. */ +async function runSources(sources: CommandSource[], argv: string[]): Promise { + // Top-level output goes through the first source: with a root source that is the one + // carrying the shared commands, otherwise the first API listed. + const top = sources[0].wiring; + const fail = (code: number, message: string): number => { + top.stderr(JSON.stringify({ error: { code, message } })); + return code; + }; + const namespaced = sources.filter( + (source): source is CommandSource & { namespace: string } => source.namespace !== undefined + ); + const root = sources.find((source) => source.namespace === undefined); + if (root !== undefined) { + const clash = root.commands.find((command) => + namespaced.some((source) => source.namespace === command.name) + ); + if (clash !== undefined) { + return fail( + 4, + `Root command "${clash.name}" collides with the "${clash.name}" namespace — rename one of them.` + ); + } + } + if (argv.length === 0 || argv[0] === '--help') { + for (const line of renderComposedHelp(sources, top.binName)) top.stdout(line); + return 0; + } + const source = namespaced.find((candidate) => candidate.namespace === argv[0]); + if (source !== undefined) return runSingle(source.commands, source.wiring, argv.slice(1)); + const rootTakes = + root !== undefined && + (argv[0] === 'schema' || + root.commands.some( + (command) => + command.name === argv[0] || + (command.group !== undefined && groupSlug(command.group) === argv[0]) + )); + if (rootTakes) + return runSingle((root as CommandSource).commands, (root as CommandSource).wiring, argv); + return fail( + 4, + `Unknown command: ${argv[0]} — expected an API namespace (${namespaced + .map((candidate) => candidate.namespace) + .join(', ')})${root !== undefined ? ' or a root command' : ''}` + ); +} + +/** The composed top-level help: namespaces, root commands, and how to descend. */ +function renderComposedHelp(sources: CommandSource[], binName: string): string[] { + const lines = [`Usage: ${binName} …`, '', 'APIs:']; + for (const source of sources) { + if (source.namespace !== undefined) lines.push(` ${source.namespace}`); + } + const root = sources.find((source) => source.namespace === undefined); + if (root !== undefined && root.commands.length > 0) { + lines.push('', 'Commands:'); + for (const command of root.commands) { + lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd()); + } + } + lines.push('', `Run ${binName} --help for that API's commands.`); + return lines; +} + +async function runSingle( + rawCommands: Array, wiring: CliWiring, argv: string[] ): Promise { const { stdout, stderr } = wiring; + const commands = normalizeCommands(rawCommands); + const shadowed = shadowedCommandName(commands); + if (shadowed !== undefined) { + stderr( + JSON.stringify({ + error: { + code: 4, + message: `Custom command "${shadowed}" collides with another command of the same name — rename it.`, + }, + }) + ); + return 4; + } const fail = (code: number, error: Record): number => { stderr(JSON.stringify({ error: { code, ...error } })); return code; @@ -448,7 +611,14 @@ export async function runCli( const invocation = parseInvocation(commands, argv); if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message }); if (invocation.kind === 'help') { - for (const line of renderHelp(commands, wiring.binName, wiring.schemes ?? [], invocation.topic)) + const prefix = wiring.envPrefix ?? envPrefix(wiring.binName); + for (const line of renderHelp( + commands, + wiring.binName, + wiring.schemes ?? [], + prefix, + invocation.topic + )) stdout(line); return 0; } @@ -524,6 +694,10 @@ export async function runCli( // by name", so one localized widening here keeps the emitted wiring cast-free. const methods = wiring.client as Record; try { + const resolved = command as ResolvedCommand; + if (resolved.handler !== undefined) { + return await resolved.handler({ positionals, params, globals, wiring }); + } if (globals.pageAll && !globals.dryRun) { const paginated = methods[command.name] as { pages: (variables?: unknown) => AsyncIterable; diff --git a/tests/e2e/generate-client/cli-compose.test.ts b/tests/e2e/generate-client/cli-compose.test.ts new file mode 100644 index 0000000000..39bd6c19e6 --- /dev/null +++ b/tests/e2e/generate-client/cli-compose.test.ts @@ -0,0 +1,133 @@ +// Composition of generated CLIs: the generated module is importable (no side effects), +// two descriptions compose behind namespaces with their own credentials, and a custom +// command with a handler joins them at the root — the login story, built in user land. +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { generate, repoRoot, tsxBin } from './helpers.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +let dir: string; + +vi.setConfig({ testTimeout: 120_000 }); + +function runEntry(args: string[], env: Record = {}) { + const result = spawnSync(tsxBin, [join(dir, 'cafe.ts'), ...args], { + cwd: dir, + encoding: 'utf-8', + env: { ...process.env, ...env }, + }); + return { code: result.status, stdout: result.stdout, stderr: result.stderr }; +} + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'cli-compose-')); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ type: 'module' }), 'utf-8'); + symlinkSync(join(repoRoot, 'node_modules'), join(dir, 'node_modules'), 'dir'); + // Two descriptions — the same fixture twice is exactly the collision case: every + // operationId exists in both, so only namespacing can tell them apart. + generate(join(__dirname, 'fixtures/cli.yaml'), join(dir, 'shop.client.ts'), [ + '--generator', + 'cli', + '--import-ext', + 'ts', + ]); + generate(join(__dirname, 'fixtures/cli.yaml'), join(dir, 'kitchen.client.ts'), [ + '--generator', + 'cli', + '--import-ext', + 'ts', + ]); + // The user-land entry: everything the extension design promises, in ~20 lines. + writeFileSync( + join(dir, 'cafe.ts'), + `import { runCli, type CustomCommand } from '@redocly/client-generator'; +import * as shop from './shop.client.cli.ts'; +import * as kitchen from './kitchen.client.cli.ts'; + +const login: CustomCommand = { + name: 'login', + summary: 'Store a token for both APIs.', + flags: [{ name: 'user', param: 'user', type: 'string', required: true }], + handler: (context) => { + context.wiring.stdout(JSON.stringify({ loggedIn: context.params.user })); + return 0; + }, +}; + +process.exit( + await runCli( + [ + { commands: [login], wiring: { ...shop.wiring, binName: 'cafe' } }, + { namespace: 'shop', commands: shop.COMMANDS, wiring: { ...shop.wiring, binName: 'cafe', envPrefix: 'CAFE_SHOP' } }, + { namespace: 'kitchen', commands: kitchen.COMMANDS, wiring: { ...kitchen.wiring, binName: 'cafe', envPrefix: 'CAFE_KITCHEN' } }, + ], + process.argv.slice(2) + ) +); +`, + 'utf-8' + ); +}); + +afterAll(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe('composed CLI (end-to-end)', () => { + it('importing the generated modules runs nothing; the entry composes them', () => { + const help = runEntry(['--help']); + expect(help.code, help.stderr).toBe(0); + expect(help.stdout).toContain('shop'); + expect(help.stdout).toContain('kitchen'); + expect(help.stdout).toContain('login Store a token for both APIs.'); + }); + + it('routes a namespace to its source with its own credential prefix', () => { + const dry = runEntry(['shop', 'orders', 'getOrder', 'ord_1', '--dry-run'], { + CAFE_SHOP_TOKEN: 'shop-secret', + }); + expect(dry.code, dry.stderr).toBe(0); + const captured = JSON.parse(dry.stdout); + expect(captured.url).toContain('/orders/ord_1'); + // The credential arrived (and was redacted) — proving the per-source prefix works. + expect(JSON.stringify(captured)).not.toContain('shop-secret'); + expect(captured.headers.Authorization).toBe('***'); + }); + + it('namespace help shows that API; the same operationId lives in both namespaces', () => { + const shop = runEntry(['shop', '--help']); + const kitchen = runEntry(['kitchen', '--help']); + expect(shop.code).toBe(0); + expect(kitchen.code).toBe(0); + expect(shop.stdout).toContain('orders'); + expect(kitchen.stdout).toContain('orders'); + }); + + it('the root custom command runs with parsed flags', () => { + const login = runEntry(['login', '--user', 'sam']); + expect(login.code, login.stderr).toBe(0); + expect(JSON.parse(login.stdout)).toEqual({ loggedIn: 'sam' }); + }); + + it('an unknown namespace is a usage error naming the real ones', () => { + const bad = runEntry(['warehouse', 'listOrders']); + expect(bad.code).toBe(4); + const message = JSON.parse(bad.stderr).error.message; + expect(message).toContain('shop'); + expect(message).toContain('kitchen'); + }); + + it('each generated CLI still works standalone', () => { + const standalone = spawnSync(tsxBin, [join(dir, 'shop.client.cli.ts'), '--help'], { + cwd: dir, + encoding: 'utf-8', + }); + expect(standalone.status, standalone.stderr).toBe(0); + expect(standalone.stdout).toContain('Usage:'); + }); +}); From 183cfa53d8b9de97cdb9c485f3c844061c65609e Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Sun, 9 Aug 2026 22:54:48 +0300 Subject: [PATCH 132/211] feat(cli): compose one binary over several APIs via client.cliOutput --- .changeset/agent-friendly-generators.md | 2 +- docs/@v2/configuration/reference/client.md | 39 ++++---- docs/@v2/guides/use-generated-client.md | 50 ++++++++++ packages/cli/src/commands/generate-client.ts | 53 ++++++++++- packages/client-generator/src/emitters/cli.ts | 91 ++++++++++++++++--- packages/client-generator/src/generate.ts | 3 + packages/client-generator/src/types.ts | 6 ++ .../__snapshots__/redocly-yaml.test.ts.snap | 3 + packages/core/src/types/redocly-yaml.ts | 1 + tests/e2e/generate-client/cli-compose.test.ts | 63 ++++++++++++- 10 files changed, 275 insertions(+), 36 deletions(-) diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index 827fe4600a..6430fb1ab9 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -3,4 +3,4 @@ '@redocly/cli': minor --- -Added agent-friendly client generation: `python`, `go`, `php`, `cli`, and `cli-docs` generators beside the TypeScript ones, a language-neutral authoring toolkit with per-generator options, and an `eject-generator` command that vendors any built-in generator — plus its design as an agent skill — into your repo. +Added agent-friendly client generation: `python`, `go`, `php`, `cli`, and `cli-docs` generators beside the TypeScript ones, composable generated CLIs (custom commands, one binary over several APIs via `client.cliOutput`), a language-neutral authoring toolkit with per-generator options, and an `eject-generator` command that vendors any built-in generator — plus its design as an agent skill — into your repo. diff --git a/docs/@v2/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md index cc784b5e5a..3fb31978da 100644 --- a/docs/@v2/configuration/reference/client.md +++ b/docs/@v2/configuration/reference/client.md @@ -17,25 +17,26 @@ Each scalar option mirrors the matching CLI flag and shares its default — see The `pagination` option is config-only — a structured, durable contract that belongs in versioned configuration rather than a shell string. For runs without a configuration file, declare pagination per operation with the `x-redoclyPagination` extension in the description, or pass `pagination` to the programmatic `generateClient(...)`. -| Option | Type | Description | -| ---------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `generators` | [string] | Generators to run, in order. Each entry is a built-in name (`sdk`, `zod`, `tanstack-query` — or its `-vue`/`-svelte`/`-solid` variants — `swr`, `mock`, `transformers`, `cli`, `python`, `go`, `php`) or a custom generator's path or package name. | -| `outputMode` | string | File layout: `single` or `split`. TypeScript output only — the `python`, `go`, and `php` SDKs always emit one self-contained file. | -| `runtime` | string | Runtime distribution: `inline` or `package`. TypeScript output only — the `python`, `go`, and `php` SDKs always embed their runtime. | -| `importExt` | string | Extension in generated relative imports: `js` (default, for tsc and bundlers) or `ts` (for Node's built-in type stripping). TypeScript output only. | -| `argsStyle` | string | How operation inputs are passed: `flat` or `grouped`. 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 are throw-only (their language idiom IS the error mode) and reject `result`. | -| `dateType` | string | Type of `date`/`date-time` fields: `string` or `Date`. Every language honors it — `Date` in TypeScript, `datetime`/`date` in Python, `time.Time`/`Date` in Go, `DateTimeImmutable` in PHP. | -| `mockData` | string | Data mode for the `mock` generator: `static` or `faker`. | -| `mockSeed` | number | Seed for `faker`-mode mocks. | -| `queryKeyPrefix` | string | Leading element for every `tanstack-query` query/mutation key — namespaces the cache when several generated APIs share one QueryClient. Config-only, no flag. | -| `codeSamples` | boolean | Emit `.code-samples.yaml` — an OpenAPI Overlay adding per-operation `x-codeSamples` collected from every selected generator that implements `sample()`. Config-only, no flag. | -| `serverUrl` | string | Server URL included in the client as its default; falls back to `servers[0].url`. | -| `goPackage` | string | Package clause for the `go` generator's output. Must be a valid Go package name (lowercase letters, digits, and `_`, not starting with a digit, not a keyword) — an invalid value fails generation instead of emitting a file Go can't compile. Default `client`. | -| `binName` | string | Command name the `cli` generator uses in help output and to derive its credential environment variables. Defaults to the output stem, sanitized. | -| `options` | object | Per-generator options, keyed by generator name — validated against the schema a generator declares. See [Custom generators](../../guides/customize-client-generation.md#custom-generators). | -| `setup` | string | Path to a publisher setup module that gets included in the client — pre-configures defaults such as the server URL, retries, headers, and middleware. See [Publisher defaults](../../guides/customize-client-generation.md#publisher-defaults). | -| `pagination` | [Pagination object](#pagination-object) | Declares how the API paginates, so paginated operations gain typed `.pages()`/`.items()` async iterators. | +| Option | Type | Description | +| ---------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `generators` | [string] | Generators to run, in order. Each entry is a built-in name (`sdk`, `zod`, `tanstack-query` — or its `-vue`/`-svelte`/`-solid` variants — `swr`, `mock`, `transformers`, `cli`, `python`, `go`, `php`) or a custom generator's path or package name. | +| `outputMode` | string | File layout: `single` or `split`. TypeScript output only — the `python`, `go`, and `php` SDKs always emit one self-contained file. | +| `runtime` | string | Runtime distribution: `inline` or `package`. TypeScript output only — the `python`, `go`, and `php` SDKs always embed their runtime. | +| `importExt` | string | Extension in generated relative imports: `js` (default, for tsc and bundlers) or `ts` (for Node's built-in type stripping). TypeScript output only. | +| `argsStyle` | string | How operation inputs are passed: `flat` or `grouped`. 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 are throw-only (their language idiom IS the error mode) and reject `result`. | +| `dateType` | string | Type of `date`/`date-time` fields: `string` or `Date`. Every language honors it — `Date` in TypeScript, `datetime`/`date` in Python, `time.Time`/`Date` in Go, `DateTimeImmutable` in PHP. | +| `mockData` | string | Data mode for the `mock` generator: `static` or `faker`. | +| `mockSeed` | number | Seed for `faker`-mode mocks. | +| `queryKeyPrefix` | string | Leading element for every `tanstack-query` query/mutation key — namespaces the cache when several generated APIs share one QueryClient. Config-only, no flag. | +| `codeSamples` | boolean | Emit `.code-samples.yaml` — an OpenAPI Overlay adding per-operation `x-codeSamples` collected from every selected generator that implements `sample()`. Config-only, no flag. | +| `serverUrl` | string | Server URL included in the client as its default; falls back to `servers[0].url`. | +| `goPackage` | string | Package clause for the `go` generator's output. Must be a valid Go package name (lowercase letters, digits, and `_`, not starting with a digit, not a keyword) — an invalid value fails generation instead of emitting a file Go can't compile. Default `client`. | +| `binName` | string | Command name the `cli` generator uses in help output and to derive its credential environment variables. Defaults to the output stem, sanitized. | +| `cliOutput` | string | Path of a composed CLI entry spanning every api that selects the `cli` generator — one binary, each api addressed by its alias, with `__*` credential variables. Top-level `client` block only; see [Compose and extend the CLI](../../guides/use-generated-client.md#compose-and-extend-the-cli). | +| `options` | object | Per-generator options, keyed by generator name — validated against the schema a generator declares. See [Custom generators](../../guides/customize-client-generation.md#custom-generators). | +| `setup` | string | Path to a publisher setup module that gets included in the client — pre-configures defaults such as the server URL, retries, headers, and middleware. See [Publisher defaults](../../guides/customize-client-generation.md#publisher-defaults). | +| `pagination` | [Pagination object](#pagination-object) | Declares how the API paginates, so paginated operations gain typed `.pages()`/`.items()` async iterators. | ### Pagination object diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index dabfccc975..975cb9d5fa 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -69,6 +69,56 @@ Exit codes are a documented contract, and errors print one JSON object to stderr `schema ` prints one operation's complete contract as JSON — method and path, the path and query parameters with their types and descriptions, whether a JSON body is accepted, the request and response schemas, and the flags that change how a call behaves (`paginated`, `sse`, `blob`). It is the CLI's machine-readable surface: a script, a test harness, or an agent can discover the tool with `--help`, then read one `schema` call per command instead of parsing help text written for humans. +#### Compose and extend the CLI + +The generated module is a library as well as a binary: it exports `COMMANDS`, `wiring`, and `run`, and self-executes only when it is the process entry. +That makes two things possible without touching generated files. + +**One binary over several APIs.** +Set a top-level `client.cliOutput` and `redocly generate-client` (no api argument) emits a composed entry over every api that selects `cli` — each behind its alias from `apis:` as the namespace, reading credentials under `__*`: + +```yaml +client: + binName: cafe + cliOutput: ./src/cafe.ts + generators: [sdk, cli] +apis: + shop: { root: ./shop/openapi.yaml, clientOutput: ./src/shop.ts } + kitchen: { root: ./kitchen/openapi.yaml, clientOutput: ./src/kitchen.ts } +``` + +```sh +cafe shop listOrders --limit 3 # CAFE_SHOP_TOKEN +cafe kitchen createOrder --json @o.json # CAFE_KITCHEN_TOKEN +``` + +Colliding operationIds across descriptions are simply different commands, and each api keeps its own server URL, schemes, and credentials. + +**Commands the description doesn't have.** +A custom command is the same data shape plus a `handler`, so it inherits help, parsing, `schema`, and the exit codes. +This is how behavior that isn't in any description — a `login`, a doctor command — joins the binary, in a file you own: + +```ts +import { runCli, type CustomCommand } from '@redocly/client-generator'; +import { SOURCES } from './src/cafe.ts'; // the composed entry exports its sources + +const login: CustomCommand = { + name: 'login', + summary: 'Fetch and store a token.', + handler: async ({ wiring }) => { + const token = await deviceFlow(); // yours: any flow the API offers + saveCredentials({ CAFE_SHOP_TOKEN: token }); // yours: file, keychain, anything + wiring.stdout('Logged in.'); + return 0; + }, +}; + +process.exit(await runCli([{ commands: [login] }, ...SOURCES], process.argv.slice(2))); +``` + +Credentials resolve from `wiring.env`, so a wrapper that reads a credentials file merges it there (`env: { ...process.env, ...stored }`) and a stored token is indistinguishable from one set in the shell. +The generator itself ships no credential store and no login — every API's flow differs, so those stay yours, and this section is the recipe. + The CLI uses top-level `await`, so the nearest `package.json` must set `"type": "module"` — otherwise `tsx` reports `Top-level await is currently not supported with the "cjs" output format`, which doesn't point at the fix. To ship it as a real bin, compile with `tsc` and point `package.json`'s `bin` at the compiled file. diff --git a/packages/cli/src/commands/generate-client.ts b/packages/cli/src/commands/generate-client.ts index 365ee0563f..abcb9255d2 100644 --- a/packages/cli/src/commands/generate-client.ts +++ b/packages/cli/src/commands/generate-client.ts @@ -2,7 +2,15 @@ import { type GenerateClientConfig } from '@redocly/client-generator'; import { HandledError, isPlainObject, logger, pluralize } from '@redocly/openapi-core'; import { blue, gray, yellow } from 'colorette'; import { readFileSync } from 'node:fs'; -import { basename, dirname, extname, isAbsolute, resolve as resolvePath } from 'node:path'; +import { writeFile } from 'node:fs/promises'; +import { + basename, + dirname, + extname, + isAbsolute, + relative, + resolve as resolvePath, +} from 'node:path'; import { BUILTIN_GENERATOR_NAMES, @@ -117,6 +125,8 @@ export async function handleGenerateClient({ ); const seenOutputs = new Set(); + // Every api that emits a cli module, gathered for the composed entry (client.cliOutput). + const composable: Array<{ alias: string; cliPath: string; importExt: string }> = []; for (const { path, alias } of entrypoints) { const name = alias ?? basename(path, extname(path)); @@ -162,6 +172,14 @@ export async function handleGenerateClient({ config: aliasConfig, configDir, }); + if (clientConfig.generators?.includes('cli')) { + const importExt = clientConfig.importExt ?? 'js'; + composable.push({ + alias: name, + cliPath: outputPath.replace(/\.ts$/, `.cli.${importExt === 'ts' ? 'ts' : 'js'}`), + importExt, + }); + } const fileCount = `${result.files.length} ${pluralize('file', result.files.length)}`; const summary = `Client successfully generated: ${fileCount} (${ result.bytes @@ -174,6 +192,39 @@ export async function handleGenerateClient({ throw new HandledError(`\n❌ Failed to generate client for ${name}.\n ${message}\n`); } } + + // The composed entry: one binary over every api that selected `cli`, each behind its + // alias as a namespace. Top-level `client.cliOutput` only — a per-api block composes + // nothing — and only for the run-everything form, where all the modules exist. + const topLevelClient = ( + isPlainObject(config.resolvedConfig.client) ? config.resolvedConfig.client : {} + ) as GenerateClientConfig; + if (topLevelClient.cliOutput !== undefined && argv.api === undefined && composable.length > 0) { + const { renderComposedCliEntry } = await import('@redocly/client-generator/generate'); + const entryPath = resolvePath(configDir, topLevelClient.cliOutput); + const binName = + topLevelClient.binName ?? + basename(entryPath, extname(entryPath)) + .replace(/[^A-Za-z0-9]+/g, '-') + .toLowerCase(); + const content = renderComposedCliEntry( + composable.map(({ alias, cliPath }) => ({ + alias, + modulePath: `./${relative(dirname(entryPath), cliPath).split('\\').join('/')}`, + })), + binName + ); + await writeFile(entryPath, content, 'utf-8'); + logger.info( + '\n' + + blue( + `Composed CLI written to ${yellow(relative(process.cwd(), entryPath))} — ${composable + .map(({ alias }) => alias) + .join(', ')} behind one \`${binName}\` binary.` + ) + + '\n' + ); + } } /** Telemetry: allowlisted built-in names, custom count, and OUR helper names a diff --git a/packages/client-generator/src/emitters/cli.ts b/packages/client-generator/src/emitters/cli.ts index bed1e9a994..b27b6ecc89 100644 --- a/packages/client-generator/src/emitters/cli.ts +++ b/packages/client-generator/src/emitters/cli.ts @@ -104,6 +104,23 @@ export function commandData( return commands; } +/** + * The self-execution guard both generated entries share. Realpath on both sides: some + * runners resolve symlinks in `import.meta.url` but not in `argv[1]` (macOS temp dirs, + * installed bin symlinks); the catch covers an entry that is not a file (REPL, node -e). + */ +const ENTRY_GUARD = `function isProcessEntry(): boolean { + if (process.argv[1] === undefined) return false; + try { + return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]); + } catch { + return false; + } +} +if (isProcessEntry()) { + process.exit(await run()); +}`; + /** JSON as a TS expression: U+2028/U+2029 are line terminators in code contexts. */ function codeJson(value: unknown, indent?: number): string { return JSON.stringify(value, null, indent) @@ -181,22 +198,68 @@ export function renderCliModule(model: ApiModel, options: CliModuleOptions): str export const run = (argv: string[] = process.argv.slice(2)): Promise => runCli(COMMANDS, wiring, argv); +// Re-exported so a composed entry can run these commands without its own runtime copy. +export { runCli }; + // Self-execute only as the process entry, so importing this module is side-effect-safe: // composed binaries and login-style wrappers import COMMANDS/wiring/run instead of -// editing this generated file. Both sides are realpath-resolved — some runners resolve -// symlinks in import.meta.url but not argv[1] (macOS temp dirs, installed bins); the -// catch covers an entry that is not a file at all (REPL, node -e). -function isProcessEntry(): boolean { - if (process.argv[1] === undefined) return false; - try { - return realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1]); - } catch { - return false; - } -} -if (isProcessEntry()) { - process.exit(await run()); -}`, +// editing this generated file. +${ENTRY_GUARD}`, ]; return parts.join('\n\n') + '\n'; } + +export type ComposedCliSource = { + /** The api alias from `apis:` — it becomes the namespace the shell types. */ + alias: string; + /** Relative specifier of that api's generated cli module, extension included. */ + modulePath: string; +}; + +/** + * The composed entry `client.cliOutput` produces: one binary over every api that selected + * `cli`, each behind its alias as the namespace, with `_` credential + * prefixes. It imports `runCli` from the first source's module — generated code, so the + * inline runtime's zero-dependency promise holds — and exports `SOURCES` so an adopter + * layers custom commands (a `login`) around it without editing a generated file. + */ +export function renderComposedCliEntry(sources: ComposedCliSource[], binName: string): string { + const prefix = binName.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase(); + const identFor = (alias: string): string => alias.replace(/[^A-Za-z0-9]/g, '_'); + const imports = sources.map(({ alias, modulePath }, index) => { + const ident = identFor(alias); + const runtime = index === 0 ? ', runCli' : ''; + return `import { COMMANDS as ${ident}Commands, wiring as ${ident}Wiring${runtime} } from ${JSON.stringify(modulePath)};`; + }); + const entries = sources.map(({ alias }) => { + const ident = identFor(alias); + const namespace = kebab(alias); + const aliasPrefix = `${prefix}_${alias.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase()}`; + return ` { + namespace: ${JSON.stringify(namespace)}, + commands: ${ident}Commands, + wiring: { ...${ident}Wiring, binName: ${JSON.stringify(binName)}, envPrefix: ${JSON.stringify(aliasPrefix)} }, + },`; + }); + return ( + [ + '#!/usr/bin/env node', + HEADER, + [ + 'import { realpathSync } from "node:fs";', + 'import { fileURLToPath } from "node:url";', + ...imports, + ].join('\n'), + `/** The composed sources — import SOURCES to layer custom commands around this binary. */ +export const SOURCES = [ +${entries.join('\n')} +]; + +/** Run the composed CLI programmatically; defaults to the process argv. */ +export const run = (argv: string[] = process.argv.slice(2)): Promise => + runCli(SOURCES, argv); + +${ENTRY_GUARD}`, + ].join('\n\n') + '\n' + ); +} diff --git a/packages/client-generator/src/generate.ts b/packages/client-generator/src/generate.ts index 30d2ce1c3e..5bcbf3aeb6 100644 --- a/packages/client-generator/src/generate.ts +++ b/packages/client-generator/src/generate.ts @@ -50,3 +50,6 @@ 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'; diff --git a/packages/client-generator/src/types.ts b/packages/client-generator/src/types.ts index b952009158..f10a4a55cf 100644 --- a/packages/client-generator/src/types.ts +++ b/packages/client-generator/src/types.ts @@ -92,6 +92,12 @@ export type GenerateClientOptions = { binName?: string; /** Package clause of the `go` generator's output. Defaults to `client`. */ goPackage?: string; + /** + * Path of a COMPOSED cli entry spanning every api that selects the `cli` generator — + * one binary, each api behind its alias as a namespace. Read by the `redocly` CLI + * across apis (top-level `client` block only); `generateClient(...)` itself ignores it. + */ + cliOutput?: string; /** * Per-generator options, keyed by generator name — validated against the schema the * generator declares (`GeneratorOptionsSchema`) before it runs. Config-only, like 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 9ac884fa10..02c107e9a5 100644 --- a/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap +++ b/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap @@ -222,6 +222,9 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "binName": { "type": "string", }, + "cliOutput": { + "type": "string", + }, "codeSamples": { "type": "boolean", }, diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts index f656bf7835..823d2c5005 100644 --- a/packages/core/src/types/redocly-yaml.ts +++ b/packages/core/src/types/redocly-yaml.ts @@ -381,6 +381,7 @@ const Client: NodeType = { importExt: { enum: ['js', 'ts'] }, binName: { type: 'string' }, goPackage: { type: 'string' }, + cliOutput: { type: 'string' }, errorMode: { enum: ['throw', 'result'] }, dateType: { enum: ['string', 'Date'] }, mockData: { enum: ['static', 'faker'] }, diff --git a/tests/e2e/generate-client/cli-compose.test.ts b/tests/e2e/generate-client/cli-compose.test.ts index 39bd6c19e6..15d9f2a96b 100644 --- a/tests/e2e/generate-client/cli-compose.test.ts +++ b/tests/e2e/generate-client/cli-compose.test.ts @@ -7,7 +7,7 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { generate, repoRoot, tsxBin } from './helpers.js'; +import { cliEntry, generate, repoRoot, tsxBin } from './helpers.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -131,3 +131,64 @@ describe('composed CLI (end-to-end)', () => { expect(standalone.stdout).toContain('Usage:'); }); }); + +describe('config-driven composition (client.cliOutput)', () => { + let project: string; + + beforeAll(() => { + project = mkdtempSync(join(tmpdir(), 'cli-output-')); + writeFileSync(join(project, 'package.json'), JSON.stringify({ type: 'module' }), 'utf-8'); + symlinkSync(join(repoRoot, 'node_modules'), join(project, 'node_modules'), 'dir'); + const fixture = join(__dirname, 'fixtures/cli.yaml'); + writeFileSync( + join(project, 'redocly.yaml'), + [ + 'extends: []', + 'client:', + ' binName: cafe', + ' cliOutput: ./src/cafe.ts', + ' importExt: ts', + ' generators: [sdk, zod, cli]', + 'apis:', + ` shop: { root: ${fixture}, clientOutput: ./src/shop.ts }`, + ` kitchen: { root: ${fixture}, clientOutput: ./src/kitchen.ts }`, + '', + ].join('\n'), + 'utf-8' + ); + const generated = spawnSync( + 'node', + [cliEntry, 'generate-client', '--config', join(project, 'redocly.yaml')], + { cwd: project, encoding: 'utf-8' } + ); + expect(generated.status, generated.stderr).toBe(0); + }); + + afterAll(() => { + rmSync(project, { recursive: true, force: true }); + }); + + it('one generate run emits the composed entry over every api that selected cli', () => { + const help = spawnSync(tsxBin, [join(project, 'src/cafe.ts'), '--help'], { + cwd: project, + encoding: 'utf-8', + }); + expect(help.status, help.stderr).toBe(0); + expect(help.stdout).toContain('Usage: cafe '); + expect(help.stdout).toContain('shop'); + expect(help.stdout).toContain('kitchen'); + }); + + it('routes a namespace and reads the alias-scoped credential', () => { + const dry = spawnSync( + tsxBin, + [join(project, 'src/cafe.ts'), 'kitchen', 'orders', 'getOrder', 'ord_7', '--dry-run'], + { cwd: project, encoding: 'utf-8', env: { ...process.env, CAFE_KITCHEN_TOKEN: 'k-secret' } } + ); + expect(dry.status, dry.stderr).toBe(0); + const captured = JSON.parse(dry.stdout); + expect(captured.url).toContain('/orders/ord_7'); + expect(captured.headers.Authorization).toBe('***'); + expect(JSON.stringify(captured)).not.toContain('k-secret'); + }); +}); From a12a0f7d013b4b2303c90964a7d189097e7c40f0 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 10 Aug 2026 10:43:09 +0300 Subject: [PATCH 133/211] fix: write generated files to the path the traversal guard validated --- .../client-generator/src/__tests__/index.test.ts | 14 ++++++++++++++ packages/client-generator/src/pipeline.ts | 8 +++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/client-generator/src/__tests__/index.test.ts b/packages/client-generator/src/__tests__/index.test.ts index 5fd8b9412d..6048377179 100644 --- a/packages/client-generator/src/__tests__/index.test.ts +++ b/packages/client-generator/src/__tests__/index.test.ts @@ -124,6 +124,20 @@ describe('collectGeneratedFiles', () => { }) ).toThrow(/Generator "rogue" failed: .*escapes the output directory/); } + // A relative path resolves against the output directory — the same base the guard + // checked — never against the cwd at write time. + const relativeRegistry = new Map([ + ['relative', { run: () => [{ path: 'fixtures/data.json', content: '{}' }] }], + ]); + expect( + collectGeneratedFiles(model(), { + outputPath: '/out/api.ts', + outputMode: 'single', + emit: {}, + generators: ['relative'], + registry: relativeRegistry, + })[0].path + ).toBe('/out/fixtures/data.json'); // Subdirectories under the output directory stay legal (mock fixtures, split files). const registry = new Map([ ['nested', { run: () => [{ path: '/out/fixtures/data.json', content: '{}' }] }], diff --git a/packages/client-generator/src/pipeline.ts b/packages/client-generator/src/pipeline.ts index 19cc08d742..f22e032517 100644 --- a/packages/client-generator/src/pipeline.ts +++ b/packages/client-generator/src/pipeline.ts @@ -83,11 +83,13 @@ export function runGenerators( `Generator "${name}" failed: file path escapes the output directory: ${file.path}` ); } - if (seen.has(file.path)) { + if (seen.has(resolved)) { throw new Error(`Generator conflict: ${file.path} already emitted by an earlier generator`); } - seen.add(file.path); - files.push(file); + seen.add(resolved); + // Carry the resolved path forward so the write goes where the guard looked — + // a relative `file.path` would otherwise resolve against the cwd at write time. + files.push({ path: resolved, content: file.content }); } } return files; From 2483b7f307fdf32d445d7430bf2b8dccffdb7571 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 10 Aug 2026 10:46:39 +0300 Subject: [PATCH 134/211] fix(cli): treat a git merge-file error exit as a failure, not a conflict count --- .../commands/eject-generator.test.ts | 26 ++++++++++++++++++- packages/cli/src/commands/eject-generator.ts | 13 ++++++++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/__tests__/commands/eject-generator.test.ts b/packages/cli/src/__tests__/commands/eject-generator.test.ts index 3dc5f47bce..b0b244013a 100644 --- a/packages/cli/src/__tests__/commands/eject-generator.test.ts +++ b/packages/cli/src/__tests__/commands/eject-generator.test.ts @@ -1,4 +1,4 @@ -import { handleEjectGenerator } from '../../commands/eject-generator.js'; +import { handleEjectGenerator, threeWayMerge } from '../../commands/eject-generator.js'; import { ejectGeneratorTelemetry } from '../../utils/generate-client-telemetry.js'; import type { CommandArgs } from '../../wrapper.js'; @@ -13,6 +13,30 @@ function reset() { } } +describe('threeWayMerge', () => { + beforeEach(reset); + + it('merges cleanly and counts conflicts', () => { + const base = 'a\nb\nc\nd\ne\n'; + expect(threeWayMerge('A\nb\nc\nd\ne\n', base, 'a\nb\nc\nd\nE\n')).toEqual({ + merged: 'A\nb\nc\nd\nE\n', + conflicts: 0, + }); + const conflicted = threeWayMerge('a\nyours\nc\nd\ne\n', base, 'a\ntheirs\nc\nd\ne\n'); + expect(conflicted.conflicts).toBe(1); + expect(conflicted.merged).toContain('<<<<<<<'); + }); + + it("keeps the user's copy when git merge-file errors instead of counting conflicts", () => { + // Binary (NUL-byte) content makes `git merge-file` exit 255 with empty stdout — + // that must surface as an error, never as "255 conflicts" written over the file. + expect(() => threeWayMerge('customized\0', 'base\0', 'updated\0')).toThrow( + /could not merge the update/ + ); + expect(ejectGeneratorTelemetry.eject_generator_outcome).toBe('merge-failed'); + }); +}); + describe('eject telemetry (coarse categories only)', () => { beforeEach(reset); diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts index c1b5b49322..dada2209ce 100644 --- a/packages/cli/src/commands/eject-generator.ts +++ b/packages/cli/src/commands/eject-generator.ts @@ -117,7 +117,7 @@ function dropPointer(dir: string, ejected: string[]): void { } /** 3-way merge via `git merge-file`; returns the merged text and the conflict count. */ -function threeWayMerge( +export function threeWayMerge( customized: string, base: string, updated: string @@ -149,12 +149,21 @@ function threeWayMerge( { encoding: 'utf-8' } ); rmSync(scratch, { recursive: true, force: true }); - if (result.error || result.status === null || result.status < 0) { + if (result.error || result.status === null) { ejectGeneratorTelemetry.eject_generator_outcome = 'merge-tool-missing'; throw new HandledError( '\n❌ `--update` needs `git` on PATH for the three-way merge. Alternative: eject to a temporary directory and diff by hand.\n' ); } + // `git merge-file` exits with the conflict count truncated to 127; anything above + // that is its negative error exit, where stdout is empty — writing it would destroy + // the user's copy. + if (result.status > 127) { + ejectGeneratorTelemetry.eject_generator_outcome = 'merge-failed'; + throw new HandledError( + `\n❌ \`git merge-file\` could not merge the update (your copy is untouched): ${result.stderr.trim()}\n` + ); + } return { merged: result.stdout, conflicts: result.status }; } From cba1e0de195b48d289d17bbd0e9a6a00474db64e Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 10 Aug 2026 10:48:49 +0300 Subject: [PATCH 135/211] fix(cli): replace the built-in name entry when wiring an ejected generator into the config --- .../commands/eject-generator.test.ts | 55 ++++++++++++++++++- packages/cli/src/commands/eject-generator.ts | 25 +++++++-- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/__tests__/commands/eject-generator.test.ts b/packages/cli/src/__tests__/commands/eject-generator.test.ts index b0b244013a..d125306583 100644 --- a/packages/cli/src/__tests__/commands/eject-generator.test.ts +++ b/packages/cli/src/__tests__/commands/eject-generator.test.ts @@ -1,4 +1,9 @@ -import { handleEjectGenerator, threeWayMerge } from '../../commands/eject-generator.js'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { outdent } from 'outdent'; + +import { handleEjectGenerator, threeWayMerge, wireConfig } from '../../commands/eject-generator.js'; import { ejectGeneratorTelemetry } from '../../utils/generate-client-telemetry.js'; import type { CommandArgs } from '../../wrapper.js'; @@ -13,6 +18,54 @@ function reset() { } } +describe('wireConfig', () => { + const wire = (source: string): string => { + const dir = mkdtempSync(join(tmpdir(), 'redocly-wire-config-')); + const configPath = join(dir, 'redocly.yaml'); + writeFileSync(configPath, source, 'utf-8'); + try { + expect(wireConfig(configPath, 'php', './generators/php.mjs')).toBe(true); + return readFileSync(configPath, 'utf-8'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }; + + it('replaces a bare built-in name so the next run has no name collision', () => { + expect( + wire(outdent` + client: + generators: + - php + - sdk + `) + ).toBe(outdent` + client: + generators: + - ./generators/php.mjs + - sdk + `); + expect(wire('client:\n generators: [php, sdk]\n')).toBe( + 'client:\n generators: [./generators/php.mjs, sdk]\n' + ); + }); + + it('appends when the built-in name is not listed', () => { + expect( + wire(outdent` + client: + generators: + - sdk + `) + ).toBe(outdent` + client: + generators: + - sdk + - ./generators/php.mjs + `); + }); +}); + describe('threeWayMerge', () => { beforeEach(reset); diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts index dada2209ce..095293377f 100644 --- a/packages/cli/src/commands/eject-generator.ts +++ b/packages/cli/src/commands/eject-generator.ts @@ -231,12 +231,14 @@ function wireDependency(packages: Record): 'added' | 'present' | /** * Add the ejected file to `client.generators` in the configuration file, editing the text - * so comments and formatting survive. Only the two shapes we can extend without guessing + * so comments and formatting survive. A bare `` entry is replaced rather than kept — + * leaving both would make the next run fail on a name collision, since the ejected file + * declares the name it takes over. Only the two shapes we can extend without guessing * are handled — a block sequence and a flow sequence under a top-level `client:` — and * anything else returns false, so the caller prints the snippet instead of reshaping * someone's config. */ -function wireConfig(configPath: string | undefined, entry: string): boolean { +export function wireConfig(configPath: string | undefined, name: string, entry: string): boolean { if (configPath === undefined || !existsSync(configPath)) return false; const source = readFileSync(configPath, 'utf-8'); const lines = source.split('\n'); @@ -253,8 +255,14 @@ function wireConfig(configPath: string | undefined, entry: string): boolean { const flow = lines[generatorsLine].match(/^(\s+generators:\s*\[)(.*)\]\s*$/); if (flow !== null) { - const existing = flow[2].trim(); - lines[generatorsLine] = `${flow[1]}${existing === '' ? '' : `${existing}, `}${entry}]`; + const items = flow[2] + .split(',') + .map((item) => item.trim()) + .filter((item) => item !== ''); + const nameEntry = items.indexOf(name); + if (nameEntry === -1) items.push(entry); + else items[nameEntry] = entry; + lines[generatorsLine] = `${flow[1]}${items.join(', ')}]`; writeFileSync(configPath, lines.join('\n'), 'utf-8'); return true; } @@ -262,8 +270,13 @@ function wireConfig(configPath: string | undefined, entry: string): boolean { let lastItem = generatorsLine; let itemIndent = `${lines[generatorsLine].match(/^\s+/)![0]} `; for (let index = generatorsLine + 1; index < lines.length; index++) { - const item = lines[index].match(/^(\s+)- /); + const item = lines[index].match(/^(\s+)- (.*?)\s*$/); if (item === null) break; + if (item[2] === name) { + lines[index] = `${item[1]}- ${entry}`; + writeFileSync(configPath, lines.join('\n'), 'utf-8'); + return true; + } lastItem = index; itemIndent = item[1]; } @@ -380,7 +393,7 @@ export const handleEjectGenerator = async ({ // A bundled TypeScript generator also imports `logger`/`isPlainObject` from core, which // the toolkit depends on — worth saying out loud for a package manager that doesn't hoist. const needsCore = asset.includes(`from "${CORE_PACKAGE}"`); - const wired = wireConfig(config.configPath, configEntry); + const wired = wireConfig(config.configPath, name, configEntry); logger.info( `Ejected the "${name}" generator to ${printedTarget}.\n` + (dependency === 'added' From 098ae0b5197de60ea0c4a479e258d0cbd0de32bb Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 10 Aug 2026 10:51:23 +0300 Subject: [PATCH 136/211] fix: keep the released x-redocly-pagination extension working, warning about the rename --- .changeset/agent-friendly-generators.md | 3 +++ docs/@v2/configuration/reference/client.md | 1 + .../__tests__/build.test.ts | 18 ++++++++++++++++++ .../src/intermediate-representation/build.ts | 12 +++++++++--- 4 files changed, 31 insertions(+), 3 deletions(-) diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index 6430fb1ab9..fa29bb4753 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -4,3 +4,6 @@ --- Added agent-friendly client generation: `python`, `go`, `php`, `cli`, and `cli-docs` generators beside the TypeScript ones, composable generated CLIs (custom commands, one binary over several APIs via `client.cliOutput`), a language-neutral authoring toolkit with per-generator options, and an `eject-generator` command that vendors any built-in generator — plus its design as an agent skill — into your repo. + +**Note**: the pagination operation extension was renamed from `x-redocly-pagination` to `x-redoclyPagination`. +The old name still works and prints a rename warning. diff --git a/docs/@v2/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md index 3fb31978da..c247f7201f 100644 --- a/docs/@v2/configuration/reference/client.md +++ b/docs/@v2/configuration/reference/client.md @@ -59,6 +59,7 @@ The rules are verified at generate time: the advance parameter must be a declare A convention that doesn't fit an operation skips it; an explicit rule that doesn't fit fails generation. The `x-redoclyPagination` operation extension in the API description takes the same rule fields. Per operation, precedence is `operations[id]`, then `x-redoclyPagination`, then the convention. +The extension's former name, `x-redocly-pagination`, still works and prints a rename warning. ## Examples 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..938c8712d9 100644 --- a/packages/client-generator/src/intermediate-representation/__tests__/build.test.ts +++ b/packages/client-generator/src/intermediate-representation/__tests__/build.test.ts @@ -284,6 +284,24 @@ describe('buildOperation — x-redoclyPagination extension', () => { }); expect('paginationExtension' in op).toBe(false); }); + + it('still reads the released x-redocly-pagination name, with a rename warning', () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + try { + const extension = { style: 'cursor', cursorParam: 'cursor' }; + const op = buildOpOnly({ + paths: { + '/orders': { + get: { operationId: 'listOrders', 'x-redocly-pagination': extension, responses: {} }, + } as never, + }, + }); + expect(op.paginationExtension).toBe(extension); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('x-redoclyPagination')); + } finally { + warn.mockRestore(); + } + }); }); describe('buildOperation — param paths', () => { diff --git a/packages/client-generator/src/intermediate-representation/build.ts b/packages/client-generator/src/intermediate-representation/build.ts index 52f266ca22..fc22cbf3d5 100644 --- a/packages/client-generator/src/intermediate-representation/build.ts +++ b/packages/client-generator/src/intermediate-representation/build.ts @@ -540,9 +540,15 @@ function buildOperation( const security = resolveOperationSecurity(operation, doc, injectable); // Extensions aren't in the @redocly operation type — read loosely, like `deprecated`. - const paginationExtension = (operation as unknown as Record)[ - 'x-redoclyPagination' - ]; + const extensions = operation as unknown as Record; + let paginationExtension = extensions['x-redoclyPagination']; + // The 0.3.x releases documented `x-redocly-pagination`; keep it working, renamed aside. + if (paginationExtension === undefined && extensions['x-redocly-pagination'] !== undefined) { + paginationExtension = extensions['x-redocly-pagination']; + logger.warn( + `generate-client: \`x-redocly-pagination\` on ${method.toUpperCase()} ${path} was renamed — use \`x-redoclyPagination\`.\n` + ); + } return { name, From cab6952046f3404fef1e55b11d5931fc6a6a8624 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 10 Aug 2026 10:55:46 +0300 Subject: [PATCH 137/211] fix(cli): merge skill edits on eject-generator --update instead of overwriting them --- docs/@v2/commands/eject-generator.md | 3 +- .../cli/src/__tests__/eject-generator.test.ts | 28 ++++-- packages/cli/src/commands/eject-generator.ts | 93 ++++++++++++++----- tests/e2e/generate-client/eject.test.ts | 4 + 4 files changed, 99 insertions(+), 29 deletions(-) diff --git a/docs/@v2/commands/eject-generator.md b/docs/@v2/commands/eject-generator.md index e804f8613e..36b0f274dd 100644 --- a/docs/@v2/commands/eject-generator.md +++ b/docs/@v2/commands/eject-generator.md @@ -38,7 +38,7 @@ Ejecting writes two things: Coding agents load skills automatically, so your agent starts from the design instead of reverse-engineering the code. A first eject also drops `.claude/skills/client-generators/SKILL.md` — the shared authoring guide (the generator contract, the API model, the helper library). -Both skills are ours: they are rewritten on every eject and `--update`, so keep your own notes elsewhere. +The skills are yours to edit, like the generator: `--update` three-way merges your skill edits with the newer version, while a fresh eject or `--force` writes them as we ship them. Beside the code, `/AGENTS.md` gets a short pointer to the skills, so the directory explains itself to a reader who opens it cold; anything you add outside its markers survives. Eject wires itself up: it adds `@redocly/client-generator` to your `devDependencies` if it isn't there and points your config at the file, where a path entry takes over the built-in name. @@ -56,6 +56,7 @@ To roll back, delete the file and the config line. `redocly eject-generator --update` merges the version shipped by your installed `@redocly/client-generator` into your copy. The three-way merge uses the version recorded in the ejected file's header as the common ancestor, so nothing extra needs to be committed and there is no snapshot to keep in sync. +The two skills merge the same way, so design notes you added to them survive an update. Conflicts arrive as standard `<<<<<<<` markers for you to resolve. Ejected generators keep working across CLI upgrades as long as the authoring contract they were written against is compatible. diff --git a/packages/cli/src/__tests__/eject-generator.test.ts b/packages/cli/src/__tests__/eject-generator.test.ts index 6c60b55e42..c0d32f7e74 100644 --- a/packages/cli/src/__tests__/eject-generator.test.ts +++ b/packages/cli/src/__tests__/eject-generator.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { packedAsset } from '../commands/eject-generator.js'; +import { packedAssets } from '../commands/eject-generator.js'; const clientGeneratorDir = resolve( dirname(fileURLToPath(import.meta.url)), @@ -12,17 +12,31 @@ const clientGeneratorDir = resolve( // `npm pack` on a directory runs that package's prepare script, so give it room. vi.setConfig({ testTimeout: 180_000 }); -describe('packedAsset', () => { - it('reads a generator out of a packed @redocly/client-generator', () => { +describe('packedAssets', () => { + it('reads the generator and its skills out of a packed @redocly/client-generator', () => { // A directory stands in for the version spec `--update` passes: same pack, same // extraction, no registry needed to prove the mechanism. - const asset = packedAsset(clientGeneratorDir, 'php'); - expect(asset).toBe( + const members = [ + 'package/eject-assets/generators/php.mjs', + 'package/eject-assets/skills/php-generator/SKILL.md', + 'package/eject-assets/skills/not-a-member/SKILL.md', + ]; + const assets = packedAssets(clientGeneratorDir, members); + expect(assets.get(members[0])).toBe( readFileSync(join(clientGeneratorDir, 'eject-assets/generators/php.mjs'), 'utf-8') ); + expect(assets.get(members[1])).toBe( + readFileSync(join(clientGeneratorDir, 'eject-assets/skills/php-generator/SKILL.md'), 'utf-8') + ); + // A member the packed version does not ship is absent, so the caller falls back per file. + expect(assets.has(members[2])).toBe(false); }); - it('returns undefined when the spec cannot be packed, so the caller can fall back', () => { - expect(packedAsset('@redocly/client-generator@0.0.0-does-not-exist', 'php')).toBeUndefined(); + it('returns nothing when the spec cannot be packed, so the caller can fall back', () => { + expect( + packedAssets('@redocly/client-generator@0.0.0-does-not-exist', [ + 'package/eject-assets/generators/php.mjs', + ]).size + ).toBe(0); }); }); diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts index 095293377f..f6e6281c1a 100644 --- a/packages/cli/src/commands/eject-generator.ts +++ b/packages/cli/src/commands/eject-generator.ts @@ -173,32 +173,67 @@ function recordedVersion(ejected: string): string | undefined { } /** - * The asset as a past version shipped it, taken from that version's package on the + * Assets as a past version shipped them, taken from that version's package on the * registry — the header records which version to ask for, so the merge base needs * nothing committed. `spec` is anything npm can pack (a version spec; a directory in - * tests). Returns undefined when the fetch or the extraction fails, so the caller can - * fall back instead of merging against the wrong base. + * tests). Members that cannot be read are simply absent from the result, so the caller + * falls back per file instead of merging against the wrong base. */ -export function packedAsset(spec: string, name: string): string | undefined { +export function packedAssets(spec: string, members: string[]): Map { const scratch = mkdtempSync(join(tmpdir(), 'redocly-eject-base-')); + const extracted = new Map(); try { const packed = spawnSync('npm', ['pack', spec, '--pack-destination', scratch], { encoding: 'utf-8', }); - if (packed.status !== 0) return undefined; + if (packed.status !== 0) return extracted; const tarball = readdirSync(scratch).find((file) => file.endsWith('.tgz')); - if (tarball === undefined) return undefined; - const member = `package/eject-assets/generators/${name}.mjs`; - const extracted = spawnSync('tar', ['-xzf', join(scratch, tarball), '-C', scratch, member], { - encoding: 'utf-8', - }); - if (extracted.status !== 0) return undefined; - return readFileSync(join(scratch, member), 'utf-8'); + if (tarball === undefined) return extracted; + for (const member of members) { + const extraction = spawnSync('tar', ['-xzf', join(scratch, tarball), '-C', scratch, member], { + encoding: 'utf-8', + }); + if (extraction.status === 0) + extracted.set(member, readFileSync(join(scratch, member), 'utf-8')); + } + return extracted; } finally { rmSync(scratch, { recursive: true, force: true }); } } +const generatorMember = (name: string) => `package/eject-assets/generators/${name}.mjs`; +const skillMember = (skill: string) => `package/eject-assets/skills/${skill}/SKILL.md`; + +/** + * Refresh one skill during `--update`. The skill tells its owner to edit it first, so it + * gets the same three-way merge as the generator: ours is the user's copy, the base is + * the skill the recorded version shipped, theirs is the current one. Without a base (a + * legacy `.pristine` eject, a failed fetch), an edited copy is kept and the new skill + * lands beside it as `SKILL.md.new`. Returns the conflict count. + */ +function updateSkill(skill: string, assetsDir: string, baseSkill: string | undefined): number { + const target = join(process.cwd(), '.claude', 'skills', skill, 'SKILL.md'); + const updated = readFileSync(join(assetsDir, 'skills', skill, 'SKILL.md'), 'utf-8'); + if (!existsSync(target)) { + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, updated, 'utf-8'); + return 0; + } + const current = readFileSync(target, 'utf-8'); + if (current === updated) return 0; + if (baseSkill === undefined) { + writeFileSync(`${target}.new`, updated, 'utf-8'); + logger.warn( + `${relative(process.cwd(), target)} was edited and has no merge base — the new skill is beside it as SKILL.md.new.\n` + ); + return 0; + } + const { merged, conflicts } = threeWayMerge(current, baseSkill, updated); + writeFileSync(target, merged, 'utf-8'); + return conflicts; +} + /** The built-in generators already ejected into `dir`, so the pointer lists every one of them. */ function ejectedIn(dir: string): string[] { return [...EJECTABLE].filter((name) => existsSync(join(dir, `${name}.mjs`))); @@ -339,13 +374,20 @@ export const handleEjectGenerator = async ({ } const customized = readFileSync(target, 'utf-8'); const from = recordedVersion(customized); + // One pack fetches every merge base: the generator plus both skills it shipped with. + const packed = + existsSync(legacyBase) || from === toolkitVersion || from === undefined + ? new Map() + : packedAssets(`${TOOLKIT_PACKAGE}@${from}`, [ + generatorMember(name), + skillMember('client-generators'), + skillMember(`${name}-generator`), + ]); const base = existsSync(legacyBase) ? readFileSync(legacyBase, 'utf-8') : from === toolkitVersion ? asset - : from === undefined - ? undefined - : packedAsset(`${TOOLKIT_PACKAGE}@${from}`, name); + : packed.get(generatorMember(name)); if (base === undefined) { ejectGeneratorTelemetry.eject_generator_outcome = 'missing-base'; const sideBySide = `${target}.new`; @@ -362,14 +404,23 @@ export const handleEjectGenerator = async ({ `Used ${relative(process.cwd(), legacyBase)} as the merge base. Later updates read the version from the file's header, so you can delete that .pristine directory.\n` ); } - dropSkill('client-generators', assetsDir); - dropSkill(`${name}-generator`, assetsDir); + // The skills are edit-first files too, so they merge the same way the generator did. + const skillBase = (skill: string): string | undefined => + from === toolkitVersion + ? readFileSync(join(assetsDir, 'skills', skill, 'SKILL.md'), 'utf-8') + : packed.get(skillMember(skill)); + const skillConflicts = + updateSkill('client-generators', assetsDir, skillBase('client-generators')) + + updateSkill(`${name}-generator`, assetsDir, skillBase(`${name}-generator`)); dropPointer(dir, ejectedIn(dir)); - ejectGeneratorTelemetry.eject_generator_outcome = conflicts > 0 ? 'conflicts' : 'success'; - if (conflicts > 0) { - ejectGeneratorTelemetry.eject_generator_conflicts = conflicts; + const totalConflicts = conflicts + skillConflicts; + ejectGeneratorTelemetry.eject_generator_outcome = totalConflicts > 0 ? 'conflicts' : 'success'; + if (totalConflicts > 0) { + ejectGeneratorTelemetry.eject_generator_conflicts = totalConflicts; logger.warn( - `Updated ${printedTarget} with ${conflicts} conflict(s) — resolve the <<<<<<< markers, then regenerate.\n` + `Updated ${printedTarget} with ${totalConflicts} conflict(s)${ + skillConflicts > 0 ? ' (some in .claude/skills)' : '' + } — resolve the <<<<<<< markers, then regenerate.\n` ); } else { logger.info(`Updated ${printedTarget} cleanly.\n`); diff --git a/tests/e2e/generate-client/eject.test.ts b/tests/e2e/generate-client/eject.test.ts index 6cac0f162c..8fdd063635 100644 --- a/tests/e2e/generate-client/eject.test.ts +++ b/tests/e2e/generate-client/eject.test.ts @@ -180,11 +180,15 @@ describe('eject-generator (end-to-end)', () => { it('--update merges cleanly around local edits and marks real conflicts', () => { appendFileSync(join(project, 'generators/php.mjs'), '// my local customization\n'); + // The skill is edit-first too — an update must merge around a design note, not drop it. + const skillPath = join(project, '.claude/skills/php-generator/SKILL.md'); + appendFileSync(skillPath, '\n## Our fork\n\nWe keep the legacy auth header.\n'); const clean = run(project, ['eject-generator', 'php', '--update']); expect(clean.status, clean.stderr).toBe(0); expect(readFileSync(join(project, 'generators/php.mjs'), 'utf-8')).toContain( '// my local customization' ); + expect(readFileSync(skillPath, 'utf-8')).toContain('We keep the legacy auth header.'); // A `.pristine/` copy from an older CLI still works as the base, and says it can go. const legacy = join(project, 'generators/.pristine'); From 7b20bdd5a5770e7ac6bfb8cbd6d441c1a73b0f8f Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 10 Aug 2026 10:58:14 +0300 Subject: [PATCH 138/211] fix(cli): re-wire the toolkit devDependency range on eject-generator --update --- packages/cli/src/commands/eject-generator.ts | 58 +++++++++++++++----- tests/e2e/generate-client/eject.test.ts | 11 ++++ 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts index f6e6281c1a..e1d3c1fe70 100644 --- a/packages/cli/src/commands/eject-generator.ts +++ b/packages/cli/src/commands/eject-generator.ts @@ -12,6 +12,7 @@ import { import { tmpdir } from 'node:os'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import * as semver from 'semver'; import { ejectGeneratorTelemetry } from '../utils/generate-client-telemetry.js'; import { type CommandArgs } from '../wrapper.js'; @@ -242,26 +243,50 @@ function ejectedIn(dir: string): string[] { /** * Record `@redocly/client-generator` in the project's devDependencies — the ejected file * imports the authoring toolkit from it. Installing stays the user's call; this only makes - * the requirement part of the project so a fresh clone or CI gets it. Returns what happened. + * the requirement part of the project so a fresh clone or CI gets it. With `refresh` (the + * `--update` path), a recorded range that no longer covers `version` is moved to + * `^version` wherever the project keeps it — the merged file targets the new toolkit. + * Returns what happened. */ -function wireDependency(packages: Record): 'added' | 'present' | 'no-package-json' { +function wireDependency( + packages: Record, + refresh = false +): 'added' | 'updated' | 'present' | 'no-package-json' { const manifestPath = join(process.cwd(), 'package.json'); if (!existsSync(manifestPath)) return 'no-package-json'; const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as { dependencies?: Record; devDependencies?: Record; }; - const missing = Object.entries(packages).filter( - ([name]) => - manifest.dependencies?.[name] === undefined && manifest.devDependencies?.[name] === undefined - ); - if (missing.length === 0) return 'present'; - const devDependencies = { ...manifest.devDependencies, ...Object.fromEntries(missing) }; - manifest.devDependencies = Object.fromEntries( - Object.entries(devDependencies).sort(([left], [right]) => left.localeCompare(right)) - ); + let outcome: 'added' | 'updated' | 'present' = 'present'; + const missing: Record = {}; + for (const [name, version] of Object.entries(packages)) { + const section = + manifest.devDependencies?.[name] !== undefined + ? manifest.devDependencies + : manifest.dependencies?.[name] !== undefined + ? manifest.dependencies + : undefined; + if (section === undefined) { + missing[name] = `^${version}`; + outcome = 'added'; + } else if ( + refresh && + !(semver.validRange(section[name]) !== null && semver.satisfies(version, section[name])) + ) { + section[name] = `^${version}`; + if (outcome === 'present') outcome = 'updated'; + } + } + if (outcome === 'present') return 'present'; + if (Object.keys(missing).length > 0) { + const devDependencies = { ...manifest.devDependencies, ...missing }; + manifest.devDependencies = Object.fromEntries( + Object.entries(devDependencies).sort(([left], [right]) => left.localeCompare(right)) + ); + } writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf-8'); - return 'added'; + return outcome; } /** @@ -413,6 +438,13 @@ export const handleEjectGenerator = async ({ updateSkill('client-generators', assetsDir, skillBase('client-generators')) + updateSkill(`${name}-generator`, assetsDir, skillBase(`${name}-generator`)); dropPointer(dir, ejectedIn(dir)); + // The merged file targets the new toolkit; a range recorded at eject time may not. + const dependency = wireDependency({ [TOOLKIT_PACKAGE]: toolkitVersion }, true); + if (dependency === 'updated' || dependency === 'added') { + logger.info( + `Set ${TOOLKIT_PACKAGE} to ^${toolkitVersion} in package.json — run your installer.\n` + ); + } const totalConflicts = conflicts + skillConflicts; ejectGeneratorTelemetry.eject_generator_outcome = totalConflicts > 0 ? 'conflicts' : 'success'; if (totalConflicts > 0) { @@ -440,7 +472,7 @@ export const handleEjectGenerator = async ({ const designSkill = dropSkill(`${name}-generator`, assetsDir); dropPointer(dir, ejectedIn(dir)); const configEntry = `./${relative(process.cwd(), target).split('\\').join('/')}`; - const dependency = wireDependency({ [TOOLKIT_PACKAGE]: `^${toolkitVersion}` }); + const dependency = wireDependency({ [TOOLKIT_PACKAGE]: toolkitVersion }); // A bundled TypeScript generator also imports `logger`/`isPlainObject` from core, which // the toolkit depends on — worth saying out loud for a package manager that doesn't hoist. const needsCore = asset.includes(`from "${CORE_PACKAGE}"`); diff --git a/tests/e2e/generate-client/eject.test.ts b/tests/e2e/generate-client/eject.test.ts index 8fdd063635..cf02b009a5 100644 --- a/tests/e2e/generate-client/eject.test.ts +++ b/tests/e2e/generate-client/eject.test.ts @@ -97,6 +97,17 @@ describe('eject-generator (end-to-end)', () => { // Re-ejecting must not add the entry twice. expect(run(wired, ['eject-generator', 'go', '--force']).status).toBe(0); expect(readFileSync(join(wired, 'redocly.yaml'), 'utf-8').match(/go\.mjs/g)).toHaveLength(1); + + // `--update` re-wires a recorded range the new toolkit no longer satisfies. + const pinned = JSON.parse(readFileSync(join(wired, 'package.json'), 'utf-8')); + pinned.devDependencies['@redocly/client-generator'] = '^0.0.1'; + writeFileSync(join(wired, 'package.json'), JSON.stringify(pinned, null, 2), 'utf-8'); + expect(run(wired, ['eject-generator', 'go', '--update']).status).toBe(0); + expect( + JSON.parse(readFileSync(join(wired, 'package.json'), 'utf-8')).devDependencies[ + '@redocly/client-generator' + ] + ).toBe(`^${toolkitVersion}`); } finally { rmSync(wired, { recursive: true, force: true }); } From 4b2404e9d6689ba9c41307c8e3f3ab91adfccf9e Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 10 Aug 2026 11:02:19 +0300 Subject: [PATCH 139/211] fix(cli): accept --config in eject-generator --- packages/cli/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ed3ad7ed4b..b9288f0f05 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -981,6 +981,7 @@ yargs(hideBin(process.argv)) type: 'string', }) .options({ + config: { description: 'Path to the config file.', type: 'string' }, dir: { describe: 'Directory to eject into.', type: 'string', From ee592ceb5dc4beedd22044a73ff48c5f55536d7a Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 10 Aug 2026 11:02:25 +0300 Subject: [PATCH 140/211] fix(cli): compose the CLI from the emitted cli modules and create the cliOutput directory --- packages/cli/src/commands/generate-client.ts | 17 +++++++++------- tests/e2e/generate-client/cli-compose.test.ts | 20 +++++++++++++++---- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/commands/generate-client.ts b/packages/cli/src/commands/generate-client.ts index abcb9255d2..05cf3fc12e 100644 --- a/packages/cli/src/commands/generate-client.ts +++ b/packages/cli/src/commands/generate-client.ts @@ -2,7 +2,7 @@ import { type GenerateClientConfig } from '@redocly/client-generator'; import { HandledError, isPlainObject, logger, pluralize } from '@redocly/openapi-core'; import { blue, gray, yellow } from 'colorette'; import { readFileSync } from 'node:fs'; -import { writeFile } from 'node:fs/promises'; +import { mkdir, writeFile } from 'node:fs/promises'; import { basename, dirname, @@ -126,7 +126,7 @@ export async function handleGenerateClient({ const seenOutputs = new Set(); // Every api that emits a cli module, gathered for the composed entry (client.cliOutput). - const composable: Array<{ alias: string; cliPath: string; importExt: string }> = []; + const composable: Array<{ alias: string; cliPath: string }> = []; for (const { path, alias } of entrypoints) { const name = alias ?? basename(path, extname(path)); @@ -172,12 +172,14 @@ export async function handleGenerateClient({ config: aliasConfig, configDir, }); - if (clientConfig.generators?.includes('cli')) { + // The emitted module, not the config string, decides what composes: `cli` also + // arrives as an ejected path entry or as another generator's prerequisite. + const cliModule = result.files.find((file) => file.path.endsWith('.cli.ts')); + if (cliModule !== undefined) { const importExt = clientConfig.importExt ?? 'js'; composable.push({ alias: name, - cliPath: outputPath.replace(/\.ts$/, `.cli.${importExt === 'ts' ? 'ts' : 'js'}`), - importExt, + cliPath: cliModule.path.replace(/\.ts$/, importExt === 'ts' ? '.ts' : '.js'), }); } const fileCount = `${result.files.length} ${pluralize('file', result.files.length)}`; @@ -193,8 +195,8 @@ export async function handleGenerateClient({ } } - // The composed entry: one binary over every api that selected `cli`, each behind its - // alias as a namespace. Top-level `client.cliOutput` only — a per-api block composes + // The composed entry: one binary over every api that emitted a cli module, each behind + // its alias as a namespace. Top-level `client.cliOutput` only — a per-api block composes // nothing — and only for the run-everything form, where all the modules exist. const topLevelClient = ( isPlainObject(config.resolvedConfig.client) ? config.resolvedConfig.client : {} @@ -214,6 +216,7 @@ export async function handleGenerateClient({ })), binName ); + await mkdir(dirname(entryPath), { recursive: true }); await writeFile(entryPath, content, 'utf-8'); logger.info( '\n' + diff --git a/tests/e2e/generate-client/cli-compose.test.ts b/tests/e2e/generate-client/cli-compose.test.ts index 15d9f2a96b..c6a55f556a 100644 --- a/tests/e2e/generate-client/cli-compose.test.ts +++ b/tests/e2e/generate-client/cli-compose.test.ts @@ -2,7 +2,7 @@ // two descriptions compose behind namespaces with their own credentials, and a custom // command with a handler joins them at the root — the login story, built in user land. import { spawnSync } from 'node:child_process'; -import { mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -146,7 +146,8 @@ describe('config-driven composition (client.cliOutput)', () => { 'extends: []', 'client:', ' binName: cafe', - ' cliOutput: ./src/cafe.ts', + // A directory nothing else creates — the composed entry makes its own. + ' cliOutput: ./bin/cafe.ts', ' importExt: ts', ' generators: [sdk, zod, cli]', 'apis:', @@ -156,6 +157,17 @@ describe('config-driven composition (client.cliOutput)', () => { ].join('\n'), 'utf-8' ); + // Eject the cli generator first: composition keys off the emitted module, so a + // `./generators/cli.mjs` path entry must compose exactly like the built-in name. + const ejected = spawnSync( + 'node', + [cliEntry, 'eject-generator', 'cli', '--config', join(project, 'redocly.yaml')], + { cwd: project, encoding: 'utf-8' } + ); + expect(ejected.status, ejected.stderr).toBe(0); + expect(readFileSync(join(project, 'redocly.yaml'), 'utf-8')).toContain( + 'generators: [sdk, zod, ./generators/cli.mjs]' + ); const generated = spawnSync( 'node', [cliEntry, 'generate-client', '--config', join(project, 'redocly.yaml')], @@ -169,7 +181,7 @@ describe('config-driven composition (client.cliOutput)', () => { }); it('one generate run emits the composed entry over every api that selected cli', () => { - const help = spawnSync(tsxBin, [join(project, 'src/cafe.ts'), '--help'], { + const help = spawnSync(tsxBin, [join(project, 'bin/cafe.ts'), '--help'], { cwd: project, encoding: 'utf-8', }); @@ -182,7 +194,7 @@ describe('config-driven composition (client.cliOutput)', () => { it('routes a namespace and reads the alias-scoped credential', () => { const dry = spawnSync( tsxBin, - [join(project, 'src/cafe.ts'), 'kitchen', 'orders', 'getOrder', 'ord_7', '--dry-run'], + [join(project, 'bin/cafe.ts'), 'kitchen', 'orders', 'getOrder', 'ord_7', '--dry-run'], { cwd: project, encoding: 'utf-8', env: { ...process.env, CAFE_KITCHEN_TOKEN: 'k-secret' } } ); expect(dry.status, dry.stderr).toBe(0); From 596acc8975f9476ae9f6e8d410ce9101d1241fd7 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 10 Aug 2026 11:04:09 +0300 Subject: [PATCH 141/211] fix(cli): keep composed-entry import bindings legal and unique for any api alias --- .../src/emitters/__tests__/cli.test.ts | 19 ++++++++++++++++++- packages/client-generator/src/emitters/cli.ts | 10 +++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/client-generator/src/emitters/__tests__/cli.test.ts b/packages/client-generator/src/emitters/__tests__/cli.test.ts index 7ed36f020d..fcd22e268f 100644 --- a/packages/client-generator/src/emitters/__tests__/cli.test.ts +++ b/packages/client-generator/src/emitters/__tests__/cli.test.ts @@ -1,5 +1,5 @@ import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js'; -import { commandData, renderCliModule } from '../cli.js'; +import { commandData, renderCliModule, renderComposedCliEntry } from '../cli.js'; const STRING: SchemaModel = { kind: 'scalar', scalar: 'string' }; const INT: SchemaModel = { kind: 'scalar', scalar: 'integer' }; @@ -254,3 +254,20 @@ describe('renderCliModule', () => { ); }); }); + +describe('renderComposedCliEntry', () => { + it('keeps import bindings legal for digit-leading aliases and unique for colliding ones', () => { + const out = renderComposedCliEntry( + [ + { alias: '2fa-api', modulePath: './2fa.cli.js' }, + { alias: 'my-api', modulePath: './my-api.cli.js' }, + { alias: 'my.api', modulePath: './my-api-2.cli.js' }, + ], + 'cafe' + ); + expect(out).toContain('import { COMMANDS as _2fa_apiCommands'); + expect(out).toContain('COMMANDS as my_apiCommands'); + expect(out).toContain('COMMANDS as my_api_2Commands'); + expect(out).toContain('namespace: "2fa-api"'); + }); +}); diff --git a/packages/client-generator/src/emitters/cli.ts b/packages/client-generator/src/emitters/cli.ts index b27b6ecc89..5ccc15b677 100644 --- a/packages/client-generator/src/emitters/cli.ts +++ b/packages/client-generator/src/emitters/cli.ts @@ -225,7 +225,15 @@ export type ComposedCliSource = { */ export function renderComposedCliEntry(sources: ComposedCliSource[], binName: string): string { const prefix = binName.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase(); - const identFor = (alias: string): string => alias.replace(/[^A-Za-z0-9]/g, '_'); + // An identifier can't start with a digit, and two aliases can sanitize identically — + // the underscore and the index keep every import binding legal and unique. + const idents = new Map(); + sources.forEach(({ alias }, index) => { + const sanitized = alias.replace(/[^A-Za-z0-9]/g, '_'); + const legal = /^[A-Za-z_]/.test(sanitized) ? sanitized : `_${sanitized}`; + idents.set(alias, [...idents.values()].includes(legal) ? `${legal}_${index}` : legal); + }); + const identFor = (alias: string): string => idents.get(alias)!; const imports = sources.map(({ alias, modulePath }, index) => { const ident = identFor(alias); const runtime = index === 0 ? ', runCli' : ''; From 1797cf8e9a87c08a2b75940ff79829a4f115c3b7 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 10 Aug 2026 11:07:24 +0300 Subject: [PATCH 142/211] fix(cli): capture the prepared request on --dry-run for SSE commands --- .../src/emitters/runtime-sources.ts | 4 ++-- .../src/runtime/__tests__/cli.test.ts | 24 +++++++++++++++++++ packages/client-generator/src/runtime/cli.ts | 3 +++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/client-generator/src/emitters/runtime-sources.ts b/packages/client-generator/src/emitters/runtime-sources.ts index 2ffe48d859..f3efaba7ac 100644 --- a/packages/client-generator/src/emitters/runtime-sources.ts +++ b/packages/client-generator/src/emitters/runtime-sources.ts @@ -25,7 +25,7 @@ export const RUNTIME_SOURCES = { 'paginate.ts': "import type { OperationArgs } from './create-client.js';\nimport type { PaginationSpec, QueryValue, RequestOptions } from './types.js';\n\n/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `params` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nexport function resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `params[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nexport async function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.params?.[spec.param];\n while (true) {\n const params = { ...args.params };\n if (cursor !== undefined) params[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, params }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `params[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.params?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call(\n { ...args, params: { ...args.params, [spec.param]: position } },\n init\n );\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nexport async function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\nexport type LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nexport function linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nexport async function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let params = args.params;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, params }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n params = { ...args.params, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nexport async function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n", 'cli.ts': - "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n binName: string;\n /** Credential variable prefix. Defaults to `binName`, constant-cased — set it when the\n * displayed name and the credential family must differ (a composed multi-API binary). */\n envPrefix?: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\nexport type CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\nexport type CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\nexport type CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\nexport type CommandSource = {\n namespace?: string;\n commands: Array;\n wiring: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nexport function groupSlug(group: string): string {\n return group\n .trim()\n .replace(/[^A-Za-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .toLowerCase();\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nexport function envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nexport async function runCli(sources: CommandSource[], argv: string[]): Promise;\nexport async function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = sources[0].wiring;\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.binName)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, source.wiring, argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, (root as CommandSource).wiring, argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], binName: string): string[] {\n const lines = [`Usage: ${binName} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${binName} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n for (const line of renderHelp(\n commands,\n wiring.binName,\n wiring.schemes ?? [],\n prefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", + "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n binName: string;\n /** Credential variable prefix. Defaults to `binName`, constant-cased — set it when the\n * displayed name and the credential family must differ (a composed multi-API binary). */\n envPrefix?: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\nexport type CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\nexport type CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\nexport type CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\nexport type CommandSource = {\n namespace?: string;\n commands: Array;\n wiring: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nexport function groupSlug(group: string): string {\n return group\n .trim()\n .replace(/[^A-Za-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .toLowerCase();\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nexport function envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nexport async function runCli(sources: CommandSource[], argv: string[]): Promise;\nexport async function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = sources[0].wiring;\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.binName)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, source.wiring, argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, (root as CommandSource).wiring, argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], binName: string): string[] {\n const lines = [`Usage: ${binName} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${binName} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n for (const line of renderHelp(\n commands,\n wiring.binName,\n wiring.schemes ?? [],\n prefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n // An SSE method returns a lazy stream — drain the stubbed response so its fetch\n // actually runs and captures the request.\n if (command.sse) for await (const _event of result as AsyncIterable);\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", } as const; /** Inline-embed variants: imports dropped, `export` stripped outside the kept surface. */ @@ -55,7 +55,7 @@ export const RUNTIME_SOURCES_STRIPPED = { 'paginate.ts': "/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `params` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nfunction resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `params[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nasync function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.params?.[spec.param];\n while (true) {\n const params = { ...args.params };\n if (cursor !== undefined) params[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, params }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `params[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.params?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call(\n { ...args, params: { ...args.params, [spec.param]: position } },\n init\n );\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nasync function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\ntype LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nfunction linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nasync function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let params = args.params;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, params }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n params = { ...args.params, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nasync function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}", 'cli.ts': - "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\ntype CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\ntype CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\ntype CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\ntype CliWiring = {\n binName: string;\n /** Credential variable prefix. Defaults to `binName`, constant-cased — set it when the\n * displayed name and the credential family must differ (a composed multi-API binary). */\n envPrefix?: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\ntype CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\ntype CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\ntype CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\ntype CommandSource = {\n namespace?: string;\n commands: Array;\n wiring: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nfunction groupSlug(group: string): string {\n return group\n .trim()\n .replace(/[^A-Za-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .toLowerCase();\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nfunction parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nfunction envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nasync function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nasync function runCli(sources: CommandSource[], argv: string[]): Promise;\nasync function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = sources[0].wiring;\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.binName)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, source.wiring, argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, (root as CommandSource).wiring, argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], binName: string): string[] {\n const lines = [`Usage: ${binName} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${binName} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n for (const line of renderHelp(\n commands,\n wiring.binName,\n wiring.schemes ?? [],\n prefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}", + "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\ntype CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\ntype CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\ntype CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\ntype CliWiring = {\n binName: string;\n /** Credential variable prefix. Defaults to `binName`, constant-cased — set it when the\n * displayed name and the credential family must differ (a composed multi-API binary). */\n envPrefix?: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\ntype CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\ntype CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\ntype CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\ntype CommandSource = {\n namespace?: string;\n commands: Array;\n wiring: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nfunction groupSlug(group: string): string {\n return group\n .trim()\n .replace(/[^A-Za-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .toLowerCase();\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nfunction parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nfunction envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nasync function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nasync function runCli(sources: CommandSource[], argv: string[]): Promise;\nasync function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = sources[0].wiring;\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.binName)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, source.wiring, argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, (root as CommandSource).wiring, argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], binName: string): string[] {\n const lines = [`Usage: ${binName} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${binName} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n for (const line of renderHelp(\n commands,\n wiring.binName,\n wiring.schemes ?? [],\n prefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n // An SSE method returns a lazy stream — drain the stubbed response so its fetch\n // actually runs and captures the request.\n if (command.sse) for await (const _event of result as AsyncIterable);\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}", } as const; export type RuntimeModuleName = keyof typeof RUNTIME_SOURCES; diff --git a/packages/client-generator/src/runtime/__tests__/cli.test.ts b/packages/client-generator/src/runtime/__tests__/cli.test.ts index f83c4bcf4e..7a70892468 100644 --- a/packages/client-generator/src/runtime/__tests__/cli.test.ts +++ b/packages/client-generator/src/runtime/__tests__/cli.test.ts @@ -530,6 +530,30 @@ describe('runCli', () => { expect(out.map((line) => JSON.parse(line))).toEqual(events); }); + it('--dry-run on an sse command drains the lazy stream so the request is captured', async () => { + const sseCommands = [{ ...PING, name: 'streamEvents', sse: true }]; + const { wiring, configured, out } = fakeWiring(); + let injectedFetch: ((url: string, init: RequestInit) => Promise) | undefined; + wiring.configure = (config) => { + configured.push(config as Record); + const candidate = (config as { fetch?: typeof injectedFetch }).fetch; + if (candidate) injectedFetch = candidate; + }; + // Like the real runtime, the stream is lazy: nothing happens until the first pull, + // and the stubbed dry-run response carries no events to yield. + (wiring.client as Record).streamEvents = async function* () { + const stubbed = await injectedFetch?.('http://api/events', { method: 'GET', headers: {} }); + if (stubbed === undefined) yield { event: 'tick', data: 1 }; + }; + const code = await runCli(sseCommands, wiring, ['streamEvents', '--dry-run']); + expect(code).toBe(0); + expect(JSON.parse(out.join('\n'))).toEqual({ + url: 'http://api/events', + method: 'GET', + headers: {}, + }); + }); + it('blob results require --output and print a byte receipt', async () => { const blobCommands = [{ ...PING, name: 'downloadReport', blob: true }]; const writes: Array<{ path: string; bytes: number }> = []; diff --git a/packages/client-generator/src/runtime/cli.ts b/packages/client-generator/src/runtime/cli.ts index f28cfb5008..5918b8b09c 100644 --- a/packages/client-generator/src/runtime/cli.ts +++ b/packages/client-generator/src/runtime/cli.ts @@ -708,6 +708,9 @@ async function runSingle( const method = methods[command.name] as (variables?: unknown) => Promise; const result = await method(argument); if (globals.dryRun) { + // An SSE method returns a lazy stream — drain the stubbed response so its fetch + // actually runs and captures the request. + if (command.sse) for await (const _event of result as AsyncIterable); stdout(JSON.stringify(captured, null, 2)); return 0; } From 2c5b8a9d1d3d85418788def95b7777174c3c02ab Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 10 Aug 2026 11:09:47 +0300 Subject: [PATCH 143/211] fix(cli): resolve telemetry generator paths against the config dir and count a shared custom generator once --- .../generate-client-telemetry.test.ts | 35 +++++++++++++++++++ packages/cli/src/commands/generate-client.ts | 16 +++++++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/__tests__/generate-client-telemetry.test.ts b/packages/cli/src/__tests__/generate-client-telemetry.test.ts index 78836d16dd..fbb39593f9 100644 --- a/packages/cli/src/__tests__/generate-client-telemetry.test.ts +++ b/packages/cli/src/__tests__/generate-client-telemetry.test.ts @@ -1,8 +1,14 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { EJECTABLE, FRAMEWORK_VARIANTS } from '../commands/eject-generator.js'; +import { collectGeneratorUsage } from '../commands/generate-client.js'; import { BUILTIN_GENERATOR_NAMES, categorizeGenerateClientError, collectToolkitImports, + generateClientTelemetry, parseEjectedProvenance, } from '../utils/generate-client-telemetry.js'; @@ -57,6 +63,35 @@ describe('BUILTIN_GENERATOR_NAMES', () => { }); }); +describe('collectGeneratorUsage', () => { + it('resolves config-relative paths against the config dir and counts a shared custom once', () => { + for (const key of Object.keys(generateClientTelemetry)) { + delete generateClientTelemetry[key as keyof typeof generateClientTelemetry]; + } + const configDir = mkdtempSync(join(tmpdir(), 'generate-client-telemetry-')); + try { + mkdirSync(join(configDir, 'generators')); + writeFileSync( + join(configDir, 'generators/php.mjs'), + '// Ejected from @redocly/client-generator@0.3.0 — the built-in "php" generator.\n' + + "import { Printer } from '@redocly/client-generator';\n", + 'utf-8' + ); + // Two apis, the same entries — the cwd is elsewhere, only configDir resolves them. + collectGeneratorUsage(['sdk', './generators/php.mjs'], ['Printer'], configDir); + collectGeneratorUsage(['sdk', './generators/php.mjs'], ['Printer'], configDir); + expect(generateClientTelemetry).toEqual({ + generate_client_builtin_generators: ['sdk'], + generate_client_custom_generators_count: 1, + generate_client_toolkit_imports: ['Printer'], + generate_client_ejected_generators: ['php@0.3.0'], + }); + } finally { + rmSync(configDir, { recursive: true, force: true }); + } + }); +}); + describe('parseEjectedProvenance', () => { it('reads OUR provenance header — an allowlisted name and version, nothing user-authored', () => { const source = diff --git a/packages/cli/src/commands/generate-client.ts b/packages/cli/src/commands/generate-client.ts index 05cf3fc12e..2b99588296 100644 --- a/packages/cli/src/commands/generate-client.ts +++ b/packages/cli/src/commands/generate-client.ts @@ -137,7 +137,7 @@ export async function handleGenerateClient({ configDir ); const clientConfig = mergeConfig(clientBlock, cliFlags); - collectGeneratorUsage(clientConfig.generators ?? [], AUTHORING_HELPER_NAMES); + collectGeneratorUsage(clientConfig.generators ?? [], AUTHORING_HELPER_NAMES, configDir); const outputPath = argv.output !== undefined @@ -230,9 +230,16 @@ export async function handleGenerateClient({ } } +/** A custom generator shared by several apis counts once, like the built-in names. */ +const seenCustomEntries = new Set(); + /** Telemetry: allowlisted built-in names, custom count, and OUR helper names a * path generator imports — never user code, paths, or names. */ -function collectGeneratorUsage(entries: string[], knownHelpers: readonly string[]): void { +export function collectGeneratorUsage( + entries: string[], + knownHelpers: readonly string[], + configDir: string +): void { const builtins = new Set(generateClientTelemetry.generate_client_builtin_generators ?? []); const toolkitImports = new Set(generateClientTelemetry.generate_client_toolkit_imports ?? []); const ejected = new Set(generateClientTelemetry.generate_client_ejected_generators ?? []); @@ -242,10 +249,13 @@ function collectGeneratorUsage(entries: string[], knownHelpers: readonly string[ builtins.add(entry); continue; } + if (seenCustomEntries.has(entry)) continue; + seenCustomEntries.add(entry); customCount++; if (entry.startsWith('.') || isAbsolute(entry)) { try { - const source = readFileSync(entry, 'utf-8'); + // Relative entries resolve against the config's directory, like the pipeline does. + const source = readFileSync(resolvePath(configDir, entry), 'utf-8'); for (const helper of collectToolkitImports(source, knownHelpers)) { toolkitImports.add(helper); } From c8bb72a28d2b86d7831b7f7469b1790320a68ece Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 10 Aug 2026 13:04:58 +0300 Subject: [PATCH 144/211] docs: align help text, anchors, and skill intros with what the feature ships --- docs/@v2/commands/eject-generator.md | 3 +- docs/@v2/configuration/reference/client.md | 40 +++++++++---------- .../@v2/guides/customize-client-generation.md | 2 +- docs/@v2/guides/use-generated-client.md | 2 +- packages/cli/src/commands/eject-generator.ts | 2 +- packages/cli/src/index.ts | 3 +- .../src/utils/generate-client-telemetry.ts | 2 +- .../src/generators/go/AGENTS.md | 2 +- .../src/generators/java/AGENTS.md | 4 +- .../src/generators/php/AGENTS.md | 2 +- .../src/generators/python/AGENTS.md | 2 +- 11 files changed, 33 insertions(+), 31 deletions(-) diff --git a/docs/@v2/commands/eject-generator.md b/docs/@v2/commands/eject-generator.md index 36b0f274dd..3fd9fa31b3 100644 --- a/docs/@v2/commands/eject-generator.md +++ b/docs/@v2/commands/eject-generator.md @@ -5,7 +5,7 @@ The `eject-generator` command vendors a built-in client generator into your repo as an editable file — the generator becomes yours to customize, while the _generated_ client stays machine-owned and reproducible. Your agent (or you) edits the generator, `redocly generate-client` rebuilds the client, and next week's spec change regenerates with the customization intact. -Every built-in generator can be ejected: the language SDKs (`python`, `go`, `php`), the TypeScript `sdk`, and the satellites (`zod`, `mock`, `cli`, `swr`, `tanstack-query`, `transformers`). +Every built-in generator can be ejected: the language SDKs (`python`, `go`, `php`), the TypeScript `sdk`, and the satellites (`zod`, `mock`, `cli`, `cli-docs`, `swr`, `tanstack-query`, `transformers`). The `tanstack-query-vue`, `-svelte`, and `-solid` variants are the same generator with one argument changed, so eject `tanstack-query` and set the framework in your copy. ## Usage @@ -22,6 +22,7 @@ redocly eject-generator php --force | Option | Type | Description | | ---------- | ------- | ------------------------------------------------------------------------------------------------------- | | generator | string | Built-in generator to eject. | +| `--config` | string | Path to the config file. | | `--dir` | string | Directory to eject into. Default `./generators`. | | `--update` | boolean | Three-way merge the current built-in version into your customized copy; conflicts get standard markers. | | `--force` | boolean | Overwrite an existing ejected file, discarding local edits. | diff --git a/docs/@v2/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md index c247f7201f..99b0d8e961 100644 --- a/docs/@v2/configuration/reference/client.md +++ b/docs/@v2/configuration/reference/client.md @@ -17,26 +17,26 @@ Each scalar option mirrors the matching CLI flag and shares its default — see The `pagination` option is config-only — a structured, durable contract that belongs in versioned configuration rather than a shell string. For runs without a configuration file, declare pagination per operation with the `x-redoclyPagination` extension in the description, or pass `pagination` to the programmatic `generateClient(...)`. -| Option | Type | Description | -| ---------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `generators` | [string] | Generators to run, in order. Each entry is a built-in name (`sdk`, `zod`, `tanstack-query` — or its `-vue`/`-svelte`/`-solid` variants — `swr`, `mock`, `transformers`, `cli`, `python`, `go`, `php`) or a custom generator's path or package name. | -| `outputMode` | string | File layout: `single` or `split`. TypeScript output only — the `python`, `go`, and `php` SDKs always emit one self-contained file. | -| `runtime` | string | Runtime distribution: `inline` or `package`. TypeScript output only — the `python`, `go`, and `php` SDKs always embed their runtime. | -| `importExt` | string | Extension in generated relative imports: `js` (default, for tsc and bundlers) or `ts` (for Node's built-in type stripping). TypeScript output only. | -| `argsStyle` | string | How operation inputs are passed: `flat` or `grouped`. 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 are throw-only (their language idiom IS the error mode) and reject `result`. | -| `dateType` | string | Type of `date`/`date-time` fields: `string` or `Date`. Every language honors it — `Date` in TypeScript, `datetime`/`date` in Python, `time.Time`/`Date` in Go, `DateTimeImmutable` in PHP. | -| `mockData` | string | Data mode for the `mock` generator: `static` or `faker`. | -| `mockSeed` | number | Seed for `faker`-mode mocks. | -| `queryKeyPrefix` | string | Leading element for every `tanstack-query` query/mutation key — namespaces the cache when several generated APIs share one QueryClient. Config-only, no flag. | -| `codeSamples` | boolean | Emit `.code-samples.yaml` — an OpenAPI Overlay adding per-operation `x-codeSamples` collected from every selected generator that implements `sample()`. Config-only, no flag. | -| `serverUrl` | string | Server URL included in the client as its default; falls back to `servers[0].url`. | -| `goPackage` | string | Package clause for the `go` generator's output. Must be a valid Go package name (lowercase letters, digits, and `_`, not starting with a digit, not a keyword) — an invalid value fails generation instead of emitting a file Go can't compile. Default `client`. | -| `binName` | string | Command name the `cli` generator uses in help output and to derive its credential environment variables. Defaults to the output stem, sanitized. | -| `cliOutput` | string | Path of a composed CLI entry spanning every api that selects the `cli` generator — one binary, each api addressed by its alias, with `__*` credential variables. Top-level `client` block only; see [Compose and extend the CLI](../../guides/use-generated-client.md#compose-and-extend-the-cli). | -| `options` | object | Per-generator options, keyed by generator name — validated against the schema a generator declares. See [Custom generators](../../guides/customize-client-generation.md#custom-generators). | -| `setup` | string | Path to a publisher setup module that gets included in the client — pre-configures defaults such as the server URL, retries, headers, and middleware. See [Publisher defaults](../../guides/customize-client-generation.md#publisher-defaults). | -| `pagination` | [Pagination object](#pagination-object) | Declares how the API paginates, so paginated operations gain typed `.pages()`/`.items()` async iterators. | +| Option | Type | Description | +| ---------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `generators` | [string] | Generators to run, in order. Each entry is a built-in name (`sdk`, `zod`, `tanstack-query` — or its `-vue`/`-svelte`/`-solid` variants — `swr`, `mock`, `transformers`, `cli`, `python`, `go`, `php`) or a custom generator's path or package name. | +| `outputMode` | string | File layout: `single` or `split`. TypeScript output only — the `python`, `go`, and `php` SDKs always emit one self-contained file. | +| `runtime` | string | Runtime distribution: `inline` or `package`. TypeScript output only — the `python`, `go`, and `php` SDKs always embed their runtime. | +| `importExt` | string | Extension in generated relative imports: `js` (default, for tsc and bundlers) or `ts` (for Node's built-in type stripping). TypeScript output only. | +| `argsStyle` | string | How operation inputs are passed: `flat` or `grouped`. 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 are throw-only (their language idiom IS the error mode) and reject `result`. | +| `dateType` | string | Type of `date`/`date-time` fields: `string` or `Date`. Every language honors it — `Date` in TypeScript, `datetime`/`date` in Python, `time.Time`/`Date` in Go, `DateTimeImmutable` in PHP. | +| `mockData` | string | Data mode for the `mock` generator: `static` or `faker`. | +| `mockSeed` | number | Seed for `faker`-mode mocks. | +| `queryKeyPrefix` | string | Leading element for every `tanstack-query` query/mutation key — namespaces the cache when several generated APIs share one QueryClient. Config-only, no flag. | +| `codeSamples` | boolean | Emit `.code-samples.yaml` — an OpenAPI Overlay adding per-operation `x-codeSamples` collected from every selected generator that implements `sample()`. Config-only, no flag. | +| `serverUrl` | string | Server URL included in the client as its default; falls back to `servers[0].url`. | +| `goPackage` | string | Package clause for the `go` generator's output. Must be a valid Go package name (lowercase letters, digits, and `_`, not starting with a digit, not a keyword) — an invalid value fails generation instead of emitting a file Go can't compile. Default `client`. | +| `binName` | string | Command name the `cli` generator uses in help output and to derive its credential environment variables. Defaults to the output stem, sanitized. | +| `cliOutput` | string | Path of a composed CLI entry spanning every api that emits a cli module — the `cli` generator by name, ejected, or pulled in as a prerequisite — one binary, each api addressed by its alias, with `__*` credential variables. Top-level `client` block only; see [Compose and extend the CLI](../../guides/use-generated-client.md#compose-and-extend-the-cli). | +| `options` | object | Per-generator options, keyed by generator name — validated against the schema a generator declares. See [Custom generators](../../guides/customize-client-generation.md#custom-generators). | +| `setup` | string | Path to a publisher setup module that gets included in the client — pre-configures defaults such as the server URL, retries, headers, and middleware. See [Publisher defaults](../../guides/customize-client-generation.md#publisher-defaults). | +| `pagination` | [Pagination object](#pagination-object) | Declares how the API paginates, so paginated operations gain typed `.pages()`/`.items()` async iterators. | ### Pagination object diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index 7eb44af376..c19c989868 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -66,7 +66,7 @@ See the [`baked-setup` example](https://github.com/Redocly/redocly-cli/tree/main The fastest path to a customized generator is [`redocly eject-generator `](../commands/eject-generator.md): it vendors any built-in generator into `./generators/` as an editable file you own. An ejected-unmodified generator produces byte-identical output, and the path entry takes over the built-in name — regeneration survives every customization. -[`--update`](../commands/eject-generator.md#updating-an-ejected-generator) merges later built-in versions into your copy. +[`--update`](../commands/eject-generator.md#update-an-ejected-generator) merges later built-in versions into your copy. Eject also writes the generator's design as an agent skill (`.claude/skills/-generator/SKILL.md`) plus the shared authoring skill. Your agent treats the design as the source of truth: state the change there first, then make the code match — and never hand-edit generated output, only the generator. diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index 975cb9d5fa..816c68d8c5 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -75,7 +75,7 @@ The generated module is a library as well as a binary: it exports `COMMANDS`, `w That makes two things possible without touching generated files. **One binary over several APIs.** -Set a top-level `client.cliOutput` and `redocly generate-client` (no api argument) emits a composed entry over every api that selects `cli` — each behind its alias from `apis:` as the namespace, reading credentials under `__*`: +Set a top-level `client.cliOutput` and `redocly generate-client` (no api argument) emits a composed entry over every api that emits a cli module — each behind its alias from `apis:` as the namespace, reading credentials under `__*`: ```yaml client: diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts index e1d3c1fe70..63bc0776ed 100644 --- a/packages/cli/src/commands/eject-generator.ts +++ b/packages/cli/src/commands/eject-generator.ts @@ -106,7 +106,7 @@ function dropPointer(dir: string, ejected: string[]): void { const end = current.indexOf(AGENTS_END); if (begin === -1 || end === -1) { logger.warn( - `generate-client: ${target} exists without the managed markers — leaving it untouched.\n` + `eject-generator: ${target} exists without the managed markers — leaving it untouched.\n` ); return; } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index b9288f0f05..042fa8c741 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -19,6 +19,7 @@ import type { ReportFormat } from './commands/drift/engine/reporter.js'; import { type DriftArgv } from './commands/drift/index.js'; import type { FindingSeverity, MatchMode, TrafficFormat } from './commands/drift/types/index.js'; import { + EJECTABLE, handleEjectGenerator, type EjectGeneratorCommandArgv, } from './commands/eject-generator.js'; @@ -977,7 +978,7 @@ yargs(hideBin(process.argv)) (yargs) => yargs .positional('generator', { - describe: 'Built-in generator to eject (python, go, php).', + describe: `Built-in generator to eject (${[...EJECTABLE].join(', ')}).`, type: 'string', }) .options({ diff --git a/packages/cli/src/utils/generate-client-telemetry.ts b/packages/cli/src/utils/generate-client-telemetry.ts index 5df66d12a6..1119f64f57 100644 --- a/packages/cli/src/utils/generate-client-telemetry.ts +++ b/packages/cli/src/utils/generate-client-telemetry.ts @@ -84,7 +84,7 @@ export type EjectGeneratorTelemetry = { eject_generator_action?: string; /** Allowlisted built-in name only; unknown names stay unnamed. */ eject_generator_name?: string; - /** Coarse outcome: success | conflicts | already-exists | missing-pristine | merge-tool-missing | unknown-generator. */ + /** Coarse outcome: success | conflicts | already-exists | missing-target | missing-base | merge-tool-missing | merge-failed | unknown-generator | unexpected-error. */ eject_generator_outcome?: string; eject_generator_conflicts?: number; }; diff --git a/packages/client-generator/src/generators/go/AGENTS.md b/packages/client-generator/src/generators/go/AGENTS.md index 335496da53..c8c12b84ba 100644 --- a/packages/client-generator/src/generators/go/AGENTS.md +++ b/packages/client-generator/src/generators/go/AGENTS.md @@ -1,7 +1,7 @@ # The `go` generator — its skill This file is the generator's DESIGN. It ships to users on `redocly eject-generator go` -(as `generators/go.AGENTS.md`) and governs our own changes: **to change the generator, +(as the `.claude/skills/go-generator/SKILL.md` agent skill) and governs our own changes: **to change the generator, edit this skill first, then make the code match it** — a diff to `index.ts` that has no covering sentence here is incomplete. diff --git a/packages/client-generator/src/generators/java/AGENTS.md b/packages/client-generator/src/generators/java/AGENTS.md index 5696b30a46..f783232c9d 100644 --- a/packages/client-generator/src/generators/java/AGENTS.md +++ b/packages/client-generator/src/generators/java/AGENTS.md @@ -1,8 +1,8 @@ # The `java` generator — its skill (DRAFT, design under review — no code exists yet) This file is the generator's DESIGN, written before any implementation (skill-first). -Once approved it ships to users on `redocly eject-generator java` (as -`generators/java.AGENTS.md`) and governs all changes: **edit this skill first, then +Once approved it ships to users on `redocly eject-generator java` (as the +`.claude/skills/java-generator/SKILL.md` agent skill) and governs all changes: **edit this skill first, then make the code match it.** ## What it emits diff --git a/packages/client-generator/src/generators/php/AGENTS.md b/packages/client-generator/src/generators/php/AGENTS.md index b44bcd861d..ff49c38fb7 100644 --- a/packages/client-generator/src/generators/php/AGENTS.md +++ b/packages/client-generator/src/generators/php/AGENTS.md @@ -1,7 +1,7 @@ # The `php` generator — its skill This file is the generator's DESIGN. It ships to users on `redocly eject-generator php` -(as `generators/php.AGENTS.md`) and governs our own changes: **to change the generator, +(as the `.claude/skills/php-generator/SKILL.md` agent skill) and governs our own changes: **to change the generator, edit this skill first, then make the code match it** — a diff to `index.ts` that has no covering sentence here is incomplete. diff --git a/packages/client-generator/src/generators/python/AGENTS.md b/packages/client-generator/src/generators/python/AGENTS.md index 45ce1abed5..caa6e42248 100644 --- a/packages/client-generator/src/generators/python/AGENTS.md +++ b/packages/client-generator/src/generators/python/AGENTS.md @@ -1,7 +1,7 @@ # The `python` generator — its skill This file is the generator's DESIGN. It ships to users on `redocly eject-generator python` -(as `generators/python.AGENTS.md`) and governs our own changes: **to change the generator, +(as the `.claude/skills/python-generator/SKILL.md` agent skill) and governs our own changes: **to change the generator, edit this skill first, then make the code match it** — a diff to `index.ts` that has no covering sentence here is incomplete. From 6acb94eac077dd038fdfd7f41505c63a6848e68c Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 10 Aug 2026 13:05:26 +0300 Subject: [PATCH 145/211] docs: list cli-docs among the built-in generators in the client reference --- docs/@v2/configuration/reference/client.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/@v2/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md index 99b0d8e961..943ea93633 100644 --- a/docs/@v2/configuration/reference/client.md +++ b/docs/@v2/configuration/reference/client.md @@ -19,7 +19,7 @@ For runs without a configuration file, declare pagination per operation with the | Option | Type | Description | | ---------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `generators` | [string] | Generators to run, in order. Each entry is a built-in name (`sdk`, `zod`, `tanstack-query` — or its `-vue`/`-svelte`/`-solid` variants — `swr`, `mock`, `transformers`, `cli`, `python`, `go`, `php`) or a custom generator's path or package name. | +| `generators` | [string] | Generators to run, in order. Each entry is a built-in name (`sdk`, `zod`, `tanstack-query` — or its `-vue`/`-svelte`/`-solid` variants — `swr`, `mock`, `transformers`, `cli`, `cli-docs`, `python`, `go`, `php`) or a custom generator's path or package name. | | `outputMode` | string | File layout: `single` or `split`. TypeScript output only — the `python`, `go`, and `php` SDKs always emit one self-contained file. | | `runtime` | string | Runtime distribution: `inline` or `package`. TypeScript output only — the `python`, `go`, and `php` SDKs always embed their runtime. | | `importExt` | string | Extension in generated relative imports: `js` (default, for tsc and bundlers) or `ts` (for Node's built-in type stripping). TypeScript output only. | From 34b91bcc0a8a442fc1841dfe74149fa5c9a1e480 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 10 Aug 2026 13:06:46 +0300 Subject: [PATCH 146/211] test: assert shipped-skill freshness for every ejectable generator, not only the language ones --- .../__tests__/generator-skills.test.ts | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) 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 f0987198ae..f0c9187836 100644 --- a/packages/client-generator/src/generators/__tests__/generator-skills.test.ts +++ b/packages/client-generator/src/generators/__tests__/generator-skills.test.ts @@ -48,15 +48,9 @@ describe.each(LANGUAGE)('%s generator skill ships to users', (name) => { expect(readFileSync(skillPath, 'utf-8')).toContain(`${name}-runtime/`); }); - it('is what eject ships — the prepared skill is the user-repo transform of the source', () => { - // `prepare` rewrites the skill for the user's repo (their file is generators/.mjs, - // their loop is regenerate + diff — not this repo's index.ts/prepare/vitest loop); - // commit-time formatting of the source AFTER a prepare run would ship a stale copy. + it('ships without repo-only references — the user has no index.ts, prepare, or vitest', () => { const asset = join(generatorsDir, '../../eject-assets/skills', `${name}-generator`, 'SKILL.md'); const shipped = readFileSync(asset, 'utf-8'); - expect(shipped).toBe(ejectedSkill(readFileSync(skillPath, 'utf-8'), name)); - // Eject drops it as an agent skill, so it carries the frontmatter a skill needs. - expect(shipped.startsWith(`---\nname: ${name}-generator\ndescription: `)).toBe(true); expect(shipped).toContain(`generators/${name}.mjs`); expect(shipped).not.toContain('index.ts'); expect(shipped).not.toContain('npm run prepare'); @@ -80,7 +74,18 @@ describe.each(EJECTABLE)('%s ships an eject asset', (name) => { it('has a generator asset and a skill beside it', () => { expect(existsSync(join(assetsDir, 'generators', `${name}.mjs`))).toBe(true); const skill = readFileSync(join(assetsDir, 'skills', `${name}-generator`, 'SKILL.md'), 'utf-8'); - expect(skill.startsWith(`---\nname: ${name}-generator\n`)).toBe(true); + expect(skill.startsWith(`---\nname: ${name}-generator\ndescription: `)).toBe(true); + }); + + it('ships the skill fresh — the committed copy is the transform of the source', () => { + // `prepare` rewrites the skill for the user's repo; a hand edit to the shipped copy, + // or a source edit without a prepare run, would ship (and commit) a stale skill. + const shipped = readFileSync( + join(assetsDir, 'skills', `${name}-generator`, 'SKILL.md'), + 'utf-8' + ); + const source = readFileSync(join(generatorsDir, name, 'AGENTS.md'), 'utf-8'); + expect(shipped).toBe(ejectedSkill(source, name)); }); it('declares the default export the resolver loads, with a version range', () => { From 205072eb734c2d48be4af6f65fc418c9db5dfca8 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 10 Aug 2026 13:09:17 +0300 Subject: [PATCH 147/211] test: tie the generator name sets to the registry and drop a defensive catch in result collection --- .../generate-client-telemetry.test.ts | 5 +++++ packages/cli/src/wrapper.ts | 18 +++++++----------- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/__tests__/generate-client-telemetry.test.ts b/packages/cli/src/__tests__/generate-client-telemetry.test.ts index fbb39593f9..61dec71246 100644 --- a/packages/cli/src/__tests__/generate-client-telemetry.test.ts +++ b/packages/cli/src/__tests__/generate-client-telemetry.test.ts @@ -2,6 +2,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { BUILTIN_META } from '../../../client-generator/src/generators/meta.js'; import { EJECTABLE, FRAMEWORK_VARIANTS } from '../commands/eject-generator.js'; import { collectGeneratorUsage } from '../commands/generate-client.js'; import { @@ -61,6 +62,10 @@ describe('BUILTIN_GENERATOR_NAMES', () => { const builtins = [...EJECTABLE, ...FRAMEWORK_VARIANTS.keys()].sort(); expect([...BUILTIN_GENERATOR_NAMES].sort()).toEqual(builtins); }); + + it('matches the generator registry, so a new built-in cannot skip eject or telemetry', () => { + expect([...BUILTIN_GENERATOR_NAMES].sort()).toEqual(Object.keys(BUILTIN_META).sort()); + }); }); describe('collectGeneratorUsage', () => { diff --git a/packages/cli/src/wrapper.ts b/packages/cli/src/wrapper.ts index 2ddabc0704..3c078b24d2 100644 --- a/packages/cli/src/wrapper.ts +++ b/packages/cli/src/wrapper.ts @@ -95,18 +95,14 @@ export function commandWrapper( const lintRulesWithWarnings = new Set(); const lintRulesWithIgnoredProblems = new Set(); const collectResults: CollectResults = (results) => { - try { - for (const problem of results) { - if (problem.ignored) { - lintRulesWithIgnoredProblems.add(problem.ruleId); - } else if (problem.severity === 'error') { - lintRulesWithErrors.add(problem.ruleId); - } else if (problem.severity === 'warn') { - lintRulesWithWarnings.add(problem.ruleId); - } + for (const problem of results) { + if (problem.ignored) { + lintRulesWithIgnoredProblems.add(problem.ruleId); + } else if (problem.severity === 'error') { + lintRulesWithErrors.add(problem.ruleId); + } else if (problem.severity === 'warn') { + lintRulesWithWarnings.add(problem.ruleId); } - } catch (err) { - // Do nothing. } }; From 2a53c152568cb307eba9026431d678383de04220 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 10 Aug 2026 15:57:24 +0300 Subject: [PATCH 148/211] docs: restore the x-redoclyPagination note and point tsType at the generate entry --- .changeset/agent-friendly-generators.md | 2 ++ docs/@v2/guides/customize-client-generation.md | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index fa29bb4753..9881340f8f 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -7,3 +7,5 @@ Added agent-friendly client generation: `python`, `go`, `php`, `cli`, and `cli-d **Note**: the pagination operation extension was renamed from `x-redocly-pagination` to `x-redoclyPagination`. The old name still works and prints a rename warning. + +**Note:** the per-operation pagination extension is now `x-redoclyPagination`; rename it in descriptions that used `x-redocly-pagination`, which is no longer read. diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index c19c989868..7a78490492 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -154,11 +154,11 @@ The only part of `generate-client` that parses TypeScript is baking a `--setup` ### TypeScript artifacts -TypeScript is just another output language: the same package root exports the TypeScript-specific renderers beside the neutral helpers. +TypeScript is just another output language: the `@redocly/client-generator/generate` entry exports the TypeScript-specific renderers, kept off the package root so a `runtime: 'package'` client's import graph never carries the generation toolkit. `tsType` is the schema→type renderer the built-in sdk itself uses, so the mapping (refs, arrays, unions, formats, parenthesization) matches the generated client exactly: ```js -import { tsType } from '@redocly/client-generator'; +import { tsType } from '@redocly/client-generator/generate'; export default { name: 'response-map', From bce4e3bd597ecfce0d38556a31ebc2c193ca416d Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 10 Aug 2026 15:57:31 +0300 Subject: [PATCH 149/211] fix(cli): let a composed source omit its wiring, and redact the encoded basic header on dry runs --- .../skills/cli-generator/SKILL.md | 12 +++-- .../src/emitters/runtime-sources.ts | 4 +- .../src/generators/cli/AGENTS.md | 12 +++-- .../src/runtime/__tests__/cli.test.ts | 51 +++++++++++++++++++ packages/client-generator/src/runtime/cli.ts | 19 +++++-- 5 files changed, 81 insertions(+), 17 deletions(-) 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 444a0363e3..b1ca8fe7dd 100644 --- a/packages/client-generator/eject-assets/skills/cli-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/cli-generator/SKILL.md @@ -63,11 +63,13 @@ with `--help`, a `schema ` introspection command, and `--dry-run`. credentials files, login flows, and profiles are user land (or a future satellite), by design. - **One binary can span several descriptions.** `runCli` also accepts sources — each a - command list plus its OWN wiring (own base URL, schemes, credentials) behind a - namespace, so colliding operationIds across descriptions are simply different commands - (`cafe shop createOrder`, `cafe kitchen createOrder`). A namespace-less source puts - commands at the root (`cafe login`); a root command whose name matches a namespace is - rejected at startup, never shadowed. + command list plus, optionally, its OWN wiring (own base URL, schemes, credentials) + behind a namespace, so colliding operationIds across descriptions are simply different + commands (`cafe shop createOrder`, `cafe kitchen createOrder`). A namespace-less source + puts commands at the root (`cafe login`); a root command whose name matches a namespace + is rejected at startup, never shadowed. A source WITHOUT wiring inherits the first + wired source's — a root `login` shares the composed binary's identity, which is the + whole point of composing it there. - **The composed entry is generated, not hand-rolled.** A top-level `client.cliOutput` makes `redocly generate-client` (no api argument) emit one entry over every api that selected `cli`: the namespace is the api ALIAS from `apis:`, and the credential prefix diff --git a/packages/client-generator/src/emitters/runtime-sources.ts b/packages/client-generator/src/emitters/runtime-sources.ts index f3efaba7ac..b32a195078 100644 --- a/packages/client-generator/src/emitters/runtime-sources.ts +++ b/packages/client-generator/src/emitters/runtime-sources.ts @@ -25,7 +25,7 @@ export const RUNTIME_SOURCES = { 'paginate.ts': "import type { OperationArgs } from './create-client.js';\nimport type { PaginationSpec, QueryValue, RequestOptions } from './types.js';\n\n/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `params` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nexport function resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `params[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nexport async function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.params?.[spec.param];\n while (true) {\n const params = { ...args.params };\n if (cursor !== undefined) params[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, params }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `params[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.params?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call(\n { ...args, params: { ...args.params, [spec.param]: position } },\n init\n );\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nexport async function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\nexport type LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nexport function linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nexport async function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let params = args.params;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, params }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n params = { ...args.params, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nexport async function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n", 'cli.ts': - "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n binName: string;\n /** Credential variable prefix. Defaults to `binName`, constant-cased — set it when the\n * displayed name and the credential family must differ (a composed multi-API binary). */\n envPrefix?: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\nexport type CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\nexport type CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\nexport type CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\nexport type CommandSource = {\n namespace?: string;\n commands: Array;\n wiring: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nexport function groupSlug(group: string): string {\n return group\n .trim()\n .replace(/[^A-Za-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .toLowerCase();\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nexport function envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nexport async function runCli(sources: CommandSource[], argv: string[]): Promise;\nexport async function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = sources[0].wiring;\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.binName)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, source.wiring, argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, (root as CommandSource).wiring, argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], binName: string): string[] {\n const lines = [`Usage: ${binName} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${binName} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n for (const line of renderHelp(\n commands,\n wiring.binName,\n wiring.schemes ?? [],\n prefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n // An SSE method returns a lazy stream — drain the stubbed response so its fetch\n // actually runs and captures the request.\n if (command.sse) for await (const _event of result as AsyncIterable);\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", + "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n binName: string;\n /** Credential variable prefix. Defaults to `binName`, constant-cased — set it when the\n * displayed name and the credential family must differ (a composed multi-API binary). */\n envPrefix?: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\nexport type CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\nexport type CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\nexport type CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\nexport type CommandSource = {\n namespace?: string;\n commands: Array;\n /** Absent = inherit the first wired source's — a root `login` shares the binary's identity. */\n wiring?: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nexport function groupSlug(group: string): string {\n return group\n .trim()\n .replace(/[^A-Za-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .toLowerCase();\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nexport function envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nexport async function runCli(sources: CommandSource[], argv: string[]): Promise;\nexport async function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // A source without wiring inherits the first wired one, so the documented root-source\n // shape `{ commands: [login] }` works: the login shares the composed binary's identity.\n const inherited = sources.find((source) => source.wiring !== undefined)?.wiring;\n const wiringOf = (source: CommandSource): CliWiring => source.wiring ?? (inherited as CliWiring);\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = wiringOf(sources[0]);\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.binName)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, wiringOf(source), argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, wiringOf(root as CommandSource), argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], binName: string): string[] {\n const lines = [`Usage: ${binName} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${binName} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n for (const line of renderHelp(\n commands,\n wiring.binName,\n wiring.schemes ?? [],\n prefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n // Redaction matches these against header VALUES — so basic auth must contribute the\n // form the wire actually carries (`Basic ${base64(user:pass)}`), not just the raw\n // password, which never appears in the encoded header.\n const basic = auth.basic as { username: string; password: string } | undefined;\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(basic ? [basic.password, btoa(`${basic.username}:${basic.password}`)] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n // An SSE method returns a lazy stream — drain the stubbed response so its fetch\n // actually runs and captures the request.\n if (command.sse) for await (const _event of result as AsyncIterable);\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", } as const; /** Inline-embed variants: imports dropped, `export` stripped outside the kept surface. */ @@ -55,7 +55,7 @@ export const RUNTIME_SOURCES_STRIPPED = { 'paginate.ts': "/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `params` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nfunction resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `params[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nasync function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.params?.[spec.param];\n while (true) {\n const params = { ...args.params };\n if (cursor !== undefined) params[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, params }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `params[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.params?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call(\n { ...args, params: { ...args.params, [spec.param]: position } },\n init\n );\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nasync function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\ntype LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nfunction linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nasync function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let params = args.params;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, params }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n params = { ...args.params, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nasync function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}", 'cli.ts': - "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\ntype CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\ntype CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\ntype CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\ntype CliWiring = {\n binName: string;\n /** Credential variable prefix. Defaults to `binName`, constant-cased — set it when the\n * displayed name and the credential family must differ (a composed multi-API binary). */\n envPrefix?: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\ntype CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\ntype CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\ntype CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\ntype CommandSource = {\n namespace?: string;\n commands: Array;\n wiring: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nfunction groupSlug(group: string): string {\n return group\n .trim()\n .replace(/[^A-Za-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .toLowerCase();\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nfunction parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nfunction envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nasync function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nasync function runCli(sources: CommandSource[], argv: string[]): Promise;\nasync function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = sources[0].wiring;\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.binName)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, source.wiring, argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, (root as CommandSource).wiring, argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], binName: string): string[] {\n const lines = [`Usage: ${binName} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${binName} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n for (const line of renderHelp(\n commands,\n wiring.binName,\n wiring.schemes ?? [],\n prefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(auth.basic ? [(auth.basic as { password: string }).password] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n // An SSE method returns a lazy stream — drain the stubbed response so its fetch\n // actually runs and captures the request.\n if (command.sse) for await (const _event of result as AsyncIterable);\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}", + "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\ntype CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\ntype CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\ntype CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\ntype CliWiring = {\n binName: string;\n /** Credential variable prefix. Defaults to `binName`, constant-cased — set it when the\n * displayed name and the credential family must differ (a composed multi-API binary). */\n envPrefix?: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\ntype CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\ntype CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\ntype CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\ntype CommandSource = {\n namespace?: string;\n commands: Array;\n /** Absent = inherit the first wired source's — a root `login` shares the binary's identity. */\n wiring?: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nfunction groupSlug(group: string): string {\n return group\n .trim()\n .replace(/[^A-Za-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .toLowerCase();\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nfunction parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nfunction envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nasync function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nasync function runCli(sources: CommandSource[], argv: string[]): Promise;\nasync function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // A source without wiring inherits the first wired one, so the documented root-source\n // shape `{ commands: [login] }` works: the login shares the composed binary's identity.\n const inherited = sources.find((source) => source.wiring !== undefined)?.wiring;\n const wiringOf = (source: CommandSource): CliWiring => source.wiring ?? (inherited as CliWiring);\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = wiringOf(sources[0]);\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.binName)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, wiringOf(source), argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, wiringOf(root as CommandSource), argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], binName: string): string[] {\n const lines = [`Usage: ${binName} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${binName} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n for (const line of renderHelp(\n commands,\n wiring.binName,\n wiring.schemes ?? [],\n prefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n // Redaction matches these against header VALUES — so basic auth must contribute the\n // form the wire actually carries (`Basic ${base64(user:pass)}`), not just the raw\n // password, which never appears in the encoded header.\n const basic = auth.basic as { username: string; password: string } | undefined;\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(basic ? [basic.password, btoa(`${basic.username}:${basic.password}`)] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n // An SSE method returns a lazy stream — drain the stubbed response so its fetch\n // actually runs and captures the request.\n if (command.sse) for await (const _event of result as AsyncIterable);\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}", } as const; export type RuntimeModuleName = keyof typeof RUNTIME_SOURCES; diff --git a/packages/client-generator/src/generators/cli/AGENTS.md b/packages/client-generator/src/generators/cli/AGENTS.md index 9bf2a3daec..68fd760226 100644 --- a/packages/client-generator/src/generators/cli/AGENTS.md +++ b/packages/client-generator/src/generators/cli/AGENTS.md @@ -57,11 +57,13 @@ with `--help`, a `schema ` introspection command, and `--dry-run`. credentials files, login flows, and profiles are user land (or a future satellite), by design. - **One binary can span several descriptions.** `runCli` also accepts sources — each a - command list plus its OWN wiring (own base URL, schemes, credentials) behind a - namespace, so colliding operationIds across descriptions are simply different commands - (`cafe shop createOrder`, `cafe kitchen createOrder`). A namespace-less source puts - commands at the root (`cafe login`); a root command whose name matches a namespace is - rejected at startup, never shadowed. + command list plus, optionally, its OWN wiring (own base URL, schemes, credentials) + behind a namespace, so colliding operationIds across descriptions are simply different + commands (`cafe shop createOrder`, `cafe kitchen createOrder`). A namespace-less source + puts commands at the root (`cafe login`); a root command whose name matches a namespace + is rejected at startup, never shadowed. A source WITHOUT wiring inherits the first + wired source's — a root `login` shares the composed binary's identity, which is the + whole point of composing it there. - **The composed entry is generated, not hand-rolled.** A top-level `client.cliOutput` makes `redocly generate-client` (no api argument) emit one entry over every api that selected `cli`: the namespace is the api ALIAS from `apis:`, and the credential prefix diff --git a/packages/client-generator/src/runtime/__tests__/cli.test.ts b/packages/client-generator/src/runtime/__tests__/cli.test.ts index 7a70892468..432ab04067 100644 --- a/packages/client-generator/src/runtime/__tests__/cli.test.ts +++ b/packages/client-generator/src/runtime/__tests__/cli.test.ts @@ -252,6 +252,25 @@ describe('multi-source runCli (one binary, several APIs)', () => { expect(context.main.calls).toEqual([]); }); + it('a source without wiring inherits the first wired source, as the docs example relies on', async () => { + const seen: string[] = []; + const login: CustomCommand = { + name: 'login', + handler: (context) => { + seen.push(context.wiring.binName); + context.wiring.stdout('ok'); + return 0; + }, + }; + const context = sources(); + // The documented shape: `{ commands: [login] }` — no wiring at all. + const code = await runCli([{ commands: [login] }, ...context.list], ['login']); + expect(code).toBe(0); + // The handler ran with the first wired source's identity, and its stdout. + expect(seen).toEqual(['cafe']); + expect(context.main.out).toEqual(['ok']); + }); + it('a namespace-less source puts its commands at the root', async () => { const login: CustomCommand = { name: 'login', @@ -312,6 +331,38 @@ describe('wiring.envPrefix', () => { }); }); +describe('dry-run redaction covers every credential form', () => { + it('redacts a basic Authorization header, which carries the base64 form of the secret', async () => { + // Substring-matching the RAW password against header values misses the header the + // client actually sends (`Basic ${base64(user:pass)}`) — printing decodable + // credentials in the output support engineers paste into tickets. + const { wiring, out } = fakeWiring({ + schemes: [{ key: 'BasicAuth', kind: 'basic' }], + env: { CAFE_USERNAME: 'sam', CAFE_PASSWORD: 'hunter2' }, + configure: () => undefined, + }); + // The dry-run stub fetch is installed via configure; emulate the client sending the + // encoded header by calling the captured fetch ourselves. + const configured: Record[] = []; + wiring.configure = (config) => configured.push(config); + wiring.client = { + ping: async () => { + const stub = configured.find((config) => typeof config.fetch === 'function'); + const fetchStub = stub?.fetch as (url: string, init: unknown) => Promise; + return fetchStub('/ping', { + method: 'GET', + headers: { Authorization: `Basic ${btoa('sam:hunter2')}` }, + }); + }, + }; + const code = await runCli(COMMANDS, wiring, ['ping', '--dry-run']); + expect(code).toBe(0); + const captured = out.join('\n'); + expect(captured).not.toContain(btoa('sam:hunter2')); + expect(captured).toContain('***'); + }); +}); + describe('schema is the complete contract for one command', () => { it('reports parameters, body, schemas, and the behavior flags', async () => { const { wiring, out } = fakeWiring(); diff --git a/packages/client-generator/src/runtime/cli.ts b/packages/client-generator/src/runtime/cli.ts index 5918b8b09c..3e823d4f26 100644 --- a/packages/client-generator/src/runtime/cli.ts +++ b/packages/client-generator/src/runtime/cli.ts @@ -117,7 +117,8 @@ export type CommandContext = { export type CommandSource = { namespace?: string; commands: Array; - wiring: CliWiring; + /** Absent = inherit the first wired source's — a root `login` shares the binary's identity. */ + wiring?: CliWiring; }; type ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] }; @@ -521,9 +522,13 @@ export async function runCli( /** Route the first token to its source; the namespace-less source owns the root. */ async function runSources(sources: CommandSource[], argv: string[]): Promise { + // A source without wiring inherits the first wired one, so the documented root-source + // shape `{ commands: [login] }` works: the login shares the composed binary's identity. + const inherited = sources.find((source) => source.wiring !== undefined)?.wiring; + const wiringOf = (source: CommandSource): CliWiring => source.wiring ?? (inherited as CliWiring); // Top-level output goes through the first source: with a root source that is the one // carrying the shared commands, otherwise the first API listed. - const top = sources[0].wiring; + const top = wiringOf(sources[0]); const fail = (code: number, message: string): number => { top.stderr(JSON.stringify({ error: { code, message } })); return code; @@ -548,7 +553,7 @@ async function runSources(sources: CommandSource[], argv: string[]): Promise candidate.namespace === argv[0]); - if (source !== undefined) return runSingle(source.commands, source.wiring, argv.slice(1)); + if (source !== undefined) return runSingle(source.commands, wiringOf(source), argv.slice(1)); const rootTakes = root !== undefined && (argv[0] === 'schema' || @@ -558,7 +563,7 @@ async function runSources(sources: CommandSource[], argv: string[]): Promise 0) wiring.configure({ auth }); if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl }); + // Redaction matches these against header VALUES — so basic auth must contribute the + // form the wire actually carries (`Basic ${base64(user:pass)}`), not just the raw + // password, which never appears in the encoded header. + const basic = auth.basic as { username: string; password: string } | undefined; const secrets = [ ...(typeof auth.bearer === 'string' ? [auth.bearer] : []), - ...(auth.basic ? [(auth.basic as { password: string }).password] : []), + ...(basic ? [basic.password, btoa(`${basic.username}:${basic.password}`)] : []), ...Object.values((auth.apiKey as Record | undefined) ?? {}), ]; let captured: Record | undefined; From 80f1332225bddffae843e07388b9e5feae5cb076 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 10 Aug 2026 17:11:29 +0300 Subject: [PATCH 150/211] fix(cli): wire the ejected generator entry relative to the config dir, and replace quoted name entries too --- .gitignore | 1 - packages/cli/src/commands/eject-generator.ts | 26 +++++++++++++++----- packages/cli/src/index.ts | 3 +-- 3 files changed, 21 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index f392cee068..79060ae38b 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,3 @@ __changesets__.json **/.claude/first-run **/.claude/assistant-daemon-state.json __pycache__/ - diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts index 63bc0776ed..e9a4da7324 100644 --- a/packages/cli/src/commands/eject-generator.ts +++ b/packages/cli/src/commands/eject-generator.ts @@ -59,7 +59,10 @@ const AGENTS_BEGIN = ''; const AGENTS_END = ''; -/** The assets directory, resolved relative to the bundled module (repo and published alike). */ +/** + * The assets directory beside the bundled module — the CLI build copies it into `lib/`. + * Absent when running straight from `src`; build the CLI first. + */ export function ejectAssetsDir(): string { return fileURLToPath(new URL('./eject-assets/', import.meta.url)); } @@ -254,7 +257,8 @@ function wireDependency( ): 'added' | 'updated' | 'present' | 'no-package-json' { const manifestPath = join(process.cwd(), 'package.json'); if (!existsSync(manifestPath)) return 'no-package-json'; - const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as { + const manifestSource = readFileSync(manifestPath, 'utf-8'); + const manifest = JSON.parse(manifestSource) as { dependencies?: Record; devDependencies?: Record; }; @@ -285,7 +289,8 @@ function wireDependency( Object.entries(devDependencies).sort(([left], [right]) => left.localeCompare(right)) ); } - writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf-8'); + const indent = /^([ \t]+)"/m.exec(manifestSource)?.[1] ?? ' '; + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, indent)}\n`, 'utf-8'); return outcome; } @@ -312,6 +317,8 @@ export function wireConfig(configPath: string | undefined, name: string, entry: // `generators:` we found belongs to another block. if (lines.slice(clientLine + 1, generatorsLine).some((line) => /^\S/.test(line))) return false; if (source.includes(entry)) return true; + const isNameEntry = (item: string) => + item === name || item === `'${name}'` || item === `"${name}"`; const flow = lines[generatorsLine].match(/^(\s+generators:\s*\[)(.*)\]\s*$/); if (flow !== null) { @@ -319,7 +326,7 @@ export function wireConfig(configPath: string | undefined, name: string, entry: .split(',') .map((item) => item.trim()) .filter((item) => item !== ''); - const nameEntry = items.indexOf(name); + const nameEntry = items.findIndex(isNameEntry); if (nameEntry === -1) items.push(entry); else items[nameEntry] = entry; lines[generatorsLine] = `${flow[1]}${items.join(', ')}]`; @@ -332,7 +339,7 @@ export function wireConfig(configPath: string | undefined, name: string, entry: for (let index = generatorsLine + 1; index < lines.length; index++) { const item = lines[index].match(/^(\s+)- (.*?)\s*$/); if (item === null) break; - if (item[2] === name) { + if (isNameEntry(item[2])) { lines[index] = `${item[1]}- ${entry}`; writeFileSync(configPath, lines.join('\n'), 'utf-8'); return true; @@ -471,7 +478,14 @@ export const handleEjectGenerator = async ({ const authoringSkill = dropSkill('client-generators', assetsDir); const designSkill = dropSkill(`${name}-generator`, assetsDir); dropPointer(dir, ejectedIn(dir)); - const configEntry = `./${relative(process.cwd(), target).split('\\').join('/')}`; + // Config-file generator entries resolve against the config's directory, so the wired + // path is relative to it — the cwd anchors only the snippet for a config yet to exist. + const configEntry = `./${relative( + config.configPath === undefined ? process.cwd() : dirname(config.configPath), + target + ) + .split('\\') + .join('/')}`; const dependency = wireDependency({ [TOOLKIT_PACKAGE]: toolkitVersion }); // A bundled TypeScript generator also imports `logger`/`isPlainObject` from core, which // the toolkit depends on — worth saying out loud for a package manager that doesn't hoist. diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 042fa8c741..a66a0333c7 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -984,9 +984,8 @@ yargs(hideBin(process.argv)) .options({ config: { description: 'Path to the config file.', type: 'string' }, dir: { - describe: 'Directory to eject into.', + describe: 'Directory to eject into (default: ./generators).', type: 'string', - default: './generators', requiresArg: true, }, force: { From e15b8848f3065871b369a3bea2b8bd4cc5394a88 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 10 Aug 2026 17:11:43 +0300 Subject: [PATCH 151/211] =?UTF-8?q?feat!:=20drop=20the=20x-redocly-paginat?= =?UTF-8?q?ion=20fallback=20=E2=80=94=20only=20x-redoclyPagination=20is=20?= =?UTF-8?q?read?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .changeset/agent-friendly-generators.md | 5 +---- docs/@v2/configuration/reference/client.md | 1 - .../__tests__/build.test.ts | 18 ------------------ .../src/intermediate-representation/build.ts | 9 +-------- 4 files changed, 2 insertions(+), 31 deletions(-) diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index 9881340f8f..f10654d9fa 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -5,7 +5,4 @@ Added agent-friendly client generation: `python`, `go`, `php`, `cli`, and `cli-docs` generators beside the TypeScript ones, composable generated CLIs (custom commands, one binary over several APIs via `client.cliOutput`), a language-neutral authoring toolkit with per-generator options, and an `eject-generator` command that vendors any built-in generator — plus its design as an agent skill — into your repo. -**Note**: the pagination operation extension was renamed from `x-redocly-pagination` to `x-redoclyPagination`. -The old name still works and prints a rename warning. - -**Note:** the per-operation pagination extension is now `x-redoclyPagination`; rename it in descriptions that used `x-redocly-pagination`, which is no longer read. +**Note**: the pagination operation extension was renamed from `x-redocly-pagination` to `x-redoclyPagination`; the old name is no longer read. diff --git a/docs/@v2/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md index 943ea93633..4aeabe009d 100644 --- a/docs/@v2/configuration/reference/client.md +++ b/docs/@v2/configuration/reference/client.md @@ -59,7 +59,6 @@ The rules are verified at generate time: the advance parameter must be a declare A convention that doesn't fit an operation skips it; an explicit rule that doesn't fit fails generation. The `x-redoclyPagination` operation extension in the API description takes the same rule fields. Per operation, precedence is `operations[id]`, then `x-redoclyPagination`, then the convention. -The extension's former name, `x-redocly-pagination`, still works and prints a rename warning. ## Examples 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 938c8712d9..e5f4b65df3 100644 --- a/packages/client-generator/src/intermediate-representation/__tests__/build.test.ts +++ b/packages/client-generator/src/intermediate-representation/__tests__/build.test.ts @@ -284,24 +284,6 @@ describe('buildOperation — x-redoclyPagination extension', () => { }); expect('paginationExtension' in op).toBe(false); }); - - it('still reads the released x-redocly-pagination name, with a rename warning', () => { - const warn = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); - try { - const extension = { style: 'cursor', cursorParam: 'cursor' }; - const op = buildOpOnly({ - paths: { - '/orders': { - get: { operationId: 'listOrders', 'x-redocly-pagination': extension, responses: {} }, - } as never, - }, - }); - expect(op.paginationExtension).toBe(extension); - expect(warn).toHaveBeenCalledWith(expect.stringContaining('x-redoclyPagination')); - } finally { - warn.mockRestore(); - } - }); }); describe('buildOperation — param paths', () => { diff --git a/packages/client-generator/src/intermediate-representation/build.ts b/packages/client-generator/src/intermediate-representation/build.ts index fc22cbf3d5..330f84b5e1 100644 --- a/packages/client-generator/src/intermediate-representation/build.ts +++ b/packages/client-generator/src/intermediate-representation/build.ts @@ -541,14 +541,7 @@ function buildOperation( // Extensions aren't in the @redocly operation type — read loosely, like `deprecated`. const extensions = operation as unknown as Record; - let paginationExtension = extensions['x-redoclyPagination']; - // The 0.3.x releases documented `x-redocly-pagination`; keep it working, renamed aside. - if (paginationExtension === undefined && extensions['x-redocly-pagination'] !== undefined) { - paginationExtension = extensions['x-redocly-pagination']; - logger.warn( - `generate-client: \`x-redocly-pagination\` on ${method.toUpperCase()} ${path} was renamed — use \`x-redoclyPagination\`.\n` - ); - } + const paginationExtension = extensions['x-redoclyPagination']; return { name, From 6b636fab648c097c4de1422e606c1663982cca85 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 11 Aug 2026 14:38:35 +0300 Subject: [PATCH 152/211] =?UTF-8?q?chore:=20address=20review=20naming=20?= =?UTF-8?q?=E2=80=94=20client-generators=20suite/job,=20telemetry=20file?= =?UTF-8?q?=20rename,=20unsharded=20e2e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/rules/testing.md | 4 +-- .github/workflows/tests.yaml | 28 ++++++++----------- AGENTS.md | 4 +-- CONTRIBUTING.md | 8 +++--- package.json | 2 +- ....ts => client-generator-telemetry.test.ts} | 2 +- .../commands/eject-generator.test.ts | 2 +- packages/cli/src/commands/eject-generator.ts | 2 +- packages/cli/src/commands/generate-client.ts | 2 +- ...metry.ts => client-generator-telemetry.ts} | 7 ++++- packages/cli/src/utils/telemetry.ts | 4 +-- packages/cli/src/wrapper.ts | 4 +-- vitest.config.ts | 2 +- 13 files changed, 36 insertions(+), 35 deletions(-) rename packages/cli/src/__tests__/{generate-client-telemetry.test.ts => client-generator-telemetry.test.ts} (99%) rename packages/cli/src/utils/{generate-client-telemetry.ts => client-generator-telemetry.ts} (90%) diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 6cefda53d7..31fd4feeb4 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -41,7 +41,7 @@ Unit tests import from `lib/` (compiled output), not `src/` — run `npm run compile` after every change. 1. Run the full suite (`npm test`) when you touch core linting logic, and make sure all tests pass in CI. -1. Client generation has its own suite: `npm run generators` runs the client-generator unit tests plus the `tests/e2e/generate-client` bars (which compile real Python/Go/PHP/TypeScript output). - Run it for any generation change; `npm run e2e` no longer includes those tests. +1. Client generation has its own suite: `npm run client-generators` runs the client-generator unit tests plus the `tests/e2e/generate-client` bars (which compile real Python/Go/PHP/TypeScript output). + Run it for any generation change; `npm run e2e` does not include those tests. 1. Coverage thresholds (`vitest.config.ts`) are a guide, not a number to game. If a feature or fix is already covered by e2e tests, propose lowering the threshold rather than padding the suite with unit tests that only chase coverage. diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index c9c4b58a4e..02fb25a7b6 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -38,14 +38,10 @@ jobs: e2e: # Everything under tests/e2e EXCEPT generate-client, which has its own job below. - # Split across shards so no single runner carries the whole set: running all suites in - # one step was cancelled mid-run by the Actions service once the suite count grew past - # ~28 (a healthy runner, no resource exhaustion), and each shard stays well under that. + # Unsharded: the mid-run cancellations that forced sharding were traced to the + # generate-client compile bars, which now run in their own job — if this job ever gets + # cancelled mid-run again, reintroduce the shard matrix. runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - shard: [1, 2, 3] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 @@ -54,14 +50,14 @@ jobs: cache: npm - name: Install dependencies run: npm ci - - name: E2E Tests (shard ${{ matrix.shard }}/3) - run: npm run e2e -- --shard=${{ matrix.shard }}/3 + - name: E2E Tests + run: npm run e2e - generators: - # Client generation has its own job: its bars compile real Python, Go, PHP, and - # TypeScript output from generated clients (including big real-world descriptions), so - # they are the slowest tests we have and they need toolchains nothing else does. - # Keeping them here means adding another language bar cannot slow the shared e2e job. + client-generators: + # Client generation has its own job: the client-generator unit tests plus the + # generate-client e2e bars, which compile real Python, Go, PHP, and TypeScript output + # (including big real-world descriptions) — the slowest tests we have, needing + # toolchains nothing else does. Adding a language bar here cannot slow the shared e2e job. runs-on: ubuntu-latest strategy: fail-fast: false @@ -87,8 +83,8 @@ jobs: key: large-descriptions-${{ hashFiles('tests/e2e/generate-client/large-descriptions.test.ts') }} - name: Install dependencies run: npm ci - - name: Generator Tests (shard ${{ matrix.shard }}/2) - run: npm run generators -- --shard=${{ matrix.shard }}/2 + - name: Client generator tests (shard ${{ matrix.shard }}/2) + run: npm run client-generators -- --shard=${{ matrix.shard }}/2 examples: # The examples gitignore their generated clients (only zero-install-quickstart commits diff --git a/AGENTS.md b/AGENTS.md index d3db1b44ee..93fb3604a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -50,7 +50,7 @@ npm run unit -- -u npm run e2e # Run every generator test (client-generator unit + generate-client e2e) -npm run generators +npm run client-generators # Run the full test suite (compile + typecheck + unit + e2e) npm test @@ -113,7 +113,7 @@ Naming and reuse: - A `redocly.yaml` in the repository root affects unit tests in the CLI package. Remove it before running them. - Run the full suite (`npm test`) when you touch core linting logic. -- Run `npm run generators` when you touch client generation — it is the whole generator suite in one command. +- Run `npm run client-generators` when you touch client generation — it is the whole generator suite in one command. The full testing and QA rules — including the rule test pattern to copy — are in [`.claude/rules/testing.md`](./.claude/rules/testing.md). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b85d62e0dc..d7101340e7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -240,12 +240,12 @@ The order of stdout and stderr in a snapshot may differ from what you see in the ### Generator tests -Client generation has its own suite: `npm run generators` runs the `@redocly/client-generator` unit tests together with the `tests/e2e/generate-client` end-to-end tests, so one command covers everything about generation. +Client generation has its own suite: `npm run client-generators` runs the `@redocly/client-generator` unit tests together with the `tests/e2e/generate-client` end-to-end tests, so one command covers everything about generation. ```bash -npm run generators # every generator test -npm run generators -- tests/e2e/generate-client/go.test.ts # one file -npm run generators -- -t 'gofmt' # by test name +npm run client-generators # every generator test +npm run client-generators -- tests/e2e/generate-client/go.test.ts # one file +npm run client-generators -- -t 'gofmt' # by test name ``` Those e2e tests compile their output with real toolchains, so what is available decides what runs: diff --git a/package.json b/package.json index 9b4dd91343..afb91b22c1 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "test": "npm run compile && npm run typecheck && npm run unit && npm run e2e", "unit": "VITEST_SUITE=unit vitest run", "e2e": "VITEST_SUITE=e2e vitest run", - "generators": "VITEST_SUITE=generators vitest run", + "client-generators": "VITEST_SUITE=client-generators vitest run", "smoke:rebilly": "VITEST_SUITE=smoke-rebilly vitest run", "format": "oxfmt .", "format:check": "oxfmt --check .", diff --git a/packages/cli/src/__tests__/generate-client-telemetry.test.ts b/packages/cli/src/__tests__/client-generator-telemetry.test.ts similarity index 99% rename from packages/cli/src/__tests__/generate-client-telemetry.test.ts rename to packages/cli/src/__tests__/client-generator-telemetry.test.ts index 61dec71246..d76eb3db29 100644 --- a/packages/cli/src/__tests__/generate-client-telemetry.test.ts +++ b/packages/cli/src/__tests__/client-generator-telemetry.test.ts @@ -11,7 +11,7 @@ import { collectToolkitImports, generateClientTelemetry, parseEjectedProvenance, -} from '../utils/generate-client-telemetry.js'; +} from '../utils/client-generator-telemetry.js'; describe('collectToolkitImports', () => { it('returns only OUR helper names from client-generator imports — never user identifiers', () => { diff --git a/packages/cli/src/__tests__/commands/eject-generator.test.ts b/packages/cli/src/__tests__/commands/eject-generator.test.ts index d125306583..a915df8d03 100644 --- a/packages/cli/src/__tests__/commands/eject-generator.test.ts +++ b/packages/cli/src/__tests__/commands/eject-generator.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path'; import { outdent } from 'outdent'; import { handleEjectGenerator, threeWayMerge, wireConfig } from '../../commands/eject-generator.js'; -import { ejectGeneratorTelemetry } from '../../utils/generate-client-telemetry.js'; +import { ejectGeneratorTelemetry } from '../../utils/client-generator-telemetry.js'; import type { CommandArgs } from '../../wrapper.js'; const baseArgs = { version: '0.0.0', config: undefined } as unknown as Omit< diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts index e9a4da7324..834a3ac44e 100644 --- a/packages/cli/src/commands/eject-generator.ts +++ b/packages/cli/src/commands/eject-generator.ts @@ -14,7 +14,7 @@ import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import * as semver from 'semver'; -import { ejectGeneratorTelemetry } from '../utils/generate-client-telemetry.js'; +import { ejectGeneratorTelemetry } from '../utils/client-generator-telemetry.js'; import { type CommandArgs } from '../wrapper.js'; export type EjectGeneratorCommandArgv = { diff --git a/packages/cli/src/commands/generate-client.ts b/packages/cli/src/commands/generate-client.ts index 2b99588296..7c749f11ed 100644 --- a/packages/cli/src/commands/generate-client.ts +++ b/packages/cli/src/commands/generate-client.ts @@ -18,7 +18,7 @@ import { collectToolkitImports, generateClientTelemetry, parseEjectedProvenance, -} from '../utils/generate-client-telemetry.js'; +} from '../utils/client-generator-telemetry.js'; import { getFallbackApisOrExit } from '../utils/miscellaneous.js'; import { type CommandArgs } from '../wrapper.js'; diff --git a/packages/cli/src/utils/generate-client-telemetry.ts b/packages/cli/src/utils/client-generator-telemetry.ts similarity index 90% rename from packages/cli/src/utils/generate-client-telemetry.ts rename to packages/cli/src/utils/client-generator-telemetry.ts index 1119f64f57..cc7d1efdae 100644 --- a/packages/cli/src/utils/generate-client-telemetry.ts +++ b/packages/cli/src/utils/client-generator-telemetry.ts @@ -36,7 +36,12 @@ export const BUILTIN_GENERATOR_NAMES = new Set([ const IMPORT_RE = /import\s*(?:type\s*)?\{([^}]*)\}\s*from\s*['"]@redocly\/client-generator(?:\/generate)?['"]/g; -/** Names of OUR exports found in an import from '@redocly/client-generator[/generate]'. */ +/** + * Names of OUR exports found in an import from '@redocly/client-generator[/generate]'. + * Approximate by design: a regex over source text can match a commented-out import, and + * that's fine — this feeds a usage histogram of allowlisted helper names, never anything + * that gates generation. Parsing properly would put `typescript` back into the CLI path. + */ export function collectToolkitImports(source: string, knownHelpers: readonly string[]): string[] { const known = new Set(knownHelpers); const found = new Set(); diff --git a/packages/cli/src/utils/telemetry.ts b/packages/cli/src/utils/telemetry.ts index d0d68b78ec..68c830aec9 100644 --- a/packages/cli/src/utils/telemetry.ts +++ b/packages/cli/src/utils/telemetry.ts @@ -21,11 +21,11 @@ import type { Arguments } from 'yargs'; import type { CriterionObject } from '../../../core/src/typings/arazzo.js'; import { getReuniteUrl } from '../reunite/api/index.js'; import type { CommandArgv } from '../types.js'; -import { ANONYMOUS_ID_CACHE_FILE } from './constants.js'; import type { EjectGeneratorTelemetry, GenerateClientTelemetry, -} from './generate-client-telemetry.js'; +} from './client-generator-telemetry.js'; +import { ANONYMOUS_ID_CACHE_FILE } from './constants.js'; import type { ExitCode } from './miscellaneous.js'; import { respondWithinMs } from './network-check.js'; import { version } from './package.js'; diff --git a/packages/cli/src/wrapper.ts b/packages/cli/src/wrapper.ts index 3c078b24d2..9f94d8dce2 100644 --- a/packages/cli/src/wrapper.ts +++ b/packages/cli/src/wrapper.ts @@ -15,11 +15,11 @@ import { import type { Arguments } from 'yargs'; import type { CommandArgv } from './types.js'; -import { AbortFlowError, exitWithError } from './utils/error.js'; import { ejectGeneratorTelemetry, generateClientTelemetry, -} from './utils/generate-client-telemetry.js'; +} from './utils/client-generator-telemetry.js'; +import { AbortFlowError, exitWithError } from './utils/error.js'; import { loadConfigAndHandleErrors, type ExitCode } from './utils/miscellaneous.js'; import { version } from './utils/package.js'; import { diff --git a/vitest.config.ts b/vitest.config.ts index cbc87edd47..e2b5fb17fe 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -42,7 +42,7 @@ const configExtension: { [key: string]: ViteUserConfig } = { // end-to-end bars. The unit tests also run under `unit`, which keeps the coverage report // whole — they are seconds, and being able to run the whole generator surface at once is // worth that. - generators: defineConfig({ + 'client-generators': defineConfig({ test: { include: [ 'packages/client-generator/src/**/*.test.ts', From 1b626057acb3206e27979d0e2cbaa738bce5f3b8 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 11 Aug 2026 15:01:28 +0300 Subject: [PATCH 153/211] feat(cli): report the composed-CLI api count and the --update version distance in telemetry --- docs/@v2/usage-data.md | 3 ++- packages/cli/src/commands/eject-generator.ts | 6 ++++++ packages/cli/src/commands/generate-client.ts | 1 + packages/cli/src/utils/client-generator-telemetry.ts | 6 ++++++ packages/cli/src/utils/telemetry.ts | 3 +++ 5 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/@v2/usage-data.md b/docs/@v2/usage-data.md index 5830494b08..a7d26fca66 100644 --- a/docs/@v2/usage-data.md +++ b/docs/@v2/usage-data.md @@ -21,9 +21,10 @@ When a command is run, the following data is collected: - API specification type and version - names of lint rules that reported errors, warnings, or ignored problems - Arazzo x-security authentication types -- for `generate-client`: which built-in generators ran, the count of custom generators, which of the package's own exported helper names a custom generator imports, and a coarse error category on failure. +- for `generate-client`: which built-in generators ran, the count of custom generators, which of the package's own exported helper names a custom generator imports, how many APIs a composed CLI entry (`client.cliOutput`) spanned, and a coarse error category on failure. When a path-loaded generator carries the `eject-generator` provenance header, its built-in origin and the version it was ejected from are included (for example `php@0.2.0`) — the file's contents, path, and any user-chosen names are never transmitted. - for `eject-generator`: the action (`eject`, `update`, `guidance`), the built-in generator name, and a coarse outcome category (such as `success`, `conflicts` with the conflict count, `already-exists`, or `merge-tool-missing`). + An `--update` run also includes the two `@redocly/client-generator` versions involved: the one the file was ejected from and the installed one. Custom generator file contents, paths, and names are never collected. - platform (Linux, macOS, Windows) - anonymous ID (a randomly generated identifier that doesn't contain personal information) diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts index 834a3ac44e..6119f2be22 100644 --- a/packages/cli/src/commands/eject-generator.ts +++ b/packages/cli/src/commands/eject-generator.ts @@ -406,6 +406,12 @@ export const handleEjectGenerator = async ({ } const customized = readFileSync(target, 'utf-8'); const from = recordedVersion(customized); + // Version distance behind the conflict count — both OUR versions. The header is + // user-editable text, so it's recorded only when it parses as a semver version. + if (from !== undefined && semver.valid(from) !== null) { + ejectGeneratorTelemetry.eject_generator_from_version = from; + } + ejectGeneratorTelemetry.eject_generator_to_version = toolkitVersion; // One pack fetches every merge base: the generator plus both skills it shipped with. const packed = existsSync(legacyBase) || from === toolkitVersion || from === undefined diff --git a/packages/cli/src/commands/generate-client.ts b/packages/cli/src/commands/generate-client.ts index 7c749f11ed..7076b0617a 100644 --- a/packages/cli/src/commands/generate-client.ts +++ b/packages/cli/src/commands/generate-client.ts @@ -218,6 +218,7 @@ export async function handleGenerateClient({ ); await mkdir(dirname(entryPath), { recursive: true }); await writeFile(entryPath, content, 'utf-8'); + generateClientTelemetry.generate_client_composed_apis_count = composable.length; logger.info( '\n' + blue( diff --git a/packages/cli/src/utils/client-generator-telemetry.ts b/packages/cli/src/utils/client-generator-telemetry.ts index cc7d1efdae..4e29c45508 100644 --- a/packages/cli/src/utils/client-generator-telemetry.ts +++ b/packages/cli/src/utils/client-generator-telemetry.ts @@ -10,6 +10,8 @@ export type GenerateClientTelemetry = { generate_client_error_category?: string; /** Ejected built-ins in the run, as `@` (from OUR provenance header). */ generate_client_ejected_generators?: string[]; + /** How many apis the composed CLI entry (`client.cliOutput`) spanned, when one was written. */ + generate_client_composed_apis_count?: number; }; /** Populated by handleGenerateClient; spread into the telemetry payload by the wrapper. */ @@ -92,6 +94,10 @@ export type EjectGeneratorTelemetry = { /** Coarse outcome: success | conflicts | already-exists | missing-target | missing-base | merge-tool-missing | merge-failed | unknown-generator | unexpected-error. */ eject_generator_outcome?: string; eject_generator_conflicts?: number; + /** `--update` only: the toolkit version the file was ejected from — OUR version string, semver-checked. */ + eject_generator_from_version?: string; + /** `--update` only: the installed toolkit version the merge targets. */ + eject_generator_to_version?: string; }; /** Populated by the eject-generator handler; spread into the telemetry payload by the wrapper. */ diff --git a/packages/cli/src/utils/telemetry.ts b/packages/cli/src/utils/telemetry.ts index 68c830aec9..fa6d886432 100644 --- a/packages/cli/src/utils/telemetry.ts +++ b/packages/cli/src/utils/telemetry.ts @@ -152,12 +152,15 @@ export async function sendTelemetry({ ?.length ? JSON.stringify(generate_client.generate_client_ejected_generators) : undefined, + generate_client_composed_apis_count: generate_client?.generate_client_composed_apis_count, // eject-generator usage (action, allowlisted name, coarse outcome — never // user paths or user-chosen names). eject_generator_action: eject_generator?.eject_generator_action, eject_generator_name: eject_generator?.eject_generator_name, eject_generator_outcome: eject_generator?.eject_generator_outcome, eject_generator_conflicts: eject_generator?.eject_generator_conflicts, + eject_generator_from_version: eject_generator?.eject_generator_from_version, + eject_generator_to_version: eject_generator?.eject_generator_to_version, }, ]; From 076afc628879269e6e4aeb994b47bc9dd53fbfe2 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 11 Aug 2026 15:01:45 +0300 Subject: [PATCH 154/211] fix(cli): compare real paths when wiring the ejected generator into the config --- packages/cli/src/commands/eject-generator.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts index 6119f2be22..e8e93b414e 100644 --- a/packages/cli/src/commands/eject-generator.ts +++ b/packages/cli/src/commands/eject-generator.ts @@ -6,6 +6,7 @@ import { mkdtempSync, readdirSync, readFileSync, + realpathSync, rmSync, writeFileSync, } from 'node:fs'; @@ -485,10 +486,12 @@ export const handleEjectGenerator = async ({ const designSkill = dropSkill(`${name}-generator`, assetsDir); dropPointer(dir, ejectedIn(dir)); // Config-file generator entries resolve against the config's directory, so the wired - // path is relative to it — the cwd anchors only the snippet for a config yet to exist. + // path is relative to it — real paths on both sides, so a symlinked location (like + // macOS /var/folders) doesn't skew the walk. The cwd anchors only the snippet for a + // config yet to exist. const configEntry = `./${relative( - config.configPath === undefined ? process.cwd() : dirname(config.configPath), - target + config.configPath === undefined ? process.cwd() : realpathSync(dirname(config.configPath)), + realpathSync(target) ) .split('\\') .join('/')}`; From 960889c7595f5557609e1311615d54790183a6dd Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 11 Aug 2026 17:58:21 +0300 Subject: [PATCH 155/211] docs: rewrite the client-generation pages in simplified version --- docs/@v2/commands/eject-generator.md | 86 ++- docs/@v2/commands/generate-client.md | 103 +-- docs/@v2/commands/index.md | 32 +- docs/@v2/configuration/reference/client.md | 124 ++-- .../@v2/guides/customize-client-generation.md | 207 ++++-- docs/@v2/guides/use-generated-client.md | 655 ++++++++++++------ docs/@v2/usage-data.md | 47 +- 7 files changed, 800 insertions(+), 454 deletions(-) diff --git a/docs/@v2/commands/eject-generator.md b/docs/@v2/commands/eject-generator.md index 3fd9fa31b3..e9be8ce0bf 100644 --- a/docs/@v2/commands/eject-generator.md +++ b/docs/@v2/commands/eject-generator.md @@ -2,11 +2,15 @@ ## Introduction -The `eject-generator` command vendors a built-in client generator into your repo as an editable file — the generator becomes yours to customize, while the _generated_ client stays machine-owned and reproducible. -Your agent (or you) edits the generator, `redocly generate-client` rebuilds the client, and next week's spec change regenerates with the customization intact. +The `eject-generator` command copies a built-in client generator into your repository as an editable file. +You own the ejected generator and can customize it. +The _generated_ client stays generated and reproducible, so do not edit it manually. +You or your agent edit the generator, and the `redocly generate-client` command rebuilds the client. +When the spec changes later, the command regenerates the client and keeps your customization. -Every built-in generator can be ejected: the language SDKs (`python`, `go`, `php`), the TypeScript `sdk`, and the satellites (`zod`, `mock`, `cli`, `cli-docs`, `swr`, `tanstack-query`, `transformers`). -The `tanstack-query-vue`, `-svelte`, and `-solid` variants are the same generator with one argument changed, so eject `tanstack-query` and set the framework in your copy. +You can eject every built-in generator: the language SDKs (`python`, `go`, `php`), the TypeScript `sdk`, and the other generators (`zod`, `mock`, `cli`, `cli-docs`, `swr`, `tanstack-query`, `transformers`). +The `tanstack-query-vue`, `-svelte`, and `-solid` variants are the same generator with one different argument. +Eject `tanstack-query` and set the framework in your copy. ## Usage @@ -19,30 +23,47 @@ redocly eject-generator php --force ## Options -| Option | Type | Description | -| ---------- | ------- | ------------------------------------------------------------------------------------------------------- | -| generator | string | Built-in generator to eject. | -| `--config` | string | Path to the config file. | -| `--dir` | string | Directory to eject into. Default `./generators`. | -| `--update` | boolean | Three-way merge the current built-in version into your customized copy; conflicts get standard markers. | -| `--force` | boolean | Overwrite an existing ejected file, discarding local edits. | +| Option | Type | Description | +| ---------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| generator | string | The built-in generator to eject. | +| `--config` | string | The path to the config file. | +| `--dir` | string | The directory that receives the ejected files. Default `./generators`. | +| `--update` | boolean | Do a three-way merge of the current built-in version into your customized copy. The command marks conflicts with standard markers. | +| `--force` | boolean | Overwrite an existing ejected file and discard the local edits. | ## How it works -Ejecting writes two things: +The eject operation writes two files: -- `/.mjs` — the generator itself, as plain ESM you own, containing everything it needs to run standalone. - A language generator (`python`, `go`, `php`) is one self-contained file, so you get its source as we wrote it. - A TypeScript generator is a thin entry over shared emitters, so you get it bundled with those emitters: unminified, with a comment marking each source module. - Either way it imports the authoring toolkit from `@redocly/client-generator`, and a bundled one also imports `logger` and `isPlainObject` from `@redocly/openapi-core` — a dependency of the toolkit, worth adding explicitly if your package manager doesn't hoist. -- `.claude/skills/-generator/SKILL.md` — the generator's design as an agent skill: the decisions its code implements, and the loop to follow when changing it (state the change in the skill, then make the code match). - Coding agents load skills automatically, so your agent starts from the design instead of reverse-engineering the code. +- `/.mjs` is the generator itself, as a plain ESM file that you own. + It contains everything that it needs to run standalone. + A language generator (`python`, `go`, `php`) is one self-contained file. + You get its source as we wrote it. + A TypeScript generator is a thin entry point that uses shared emitters, so you get it bundled together with those emitters. + The bundle is not minified, and a comment marks each source module. -A first eject also drops `.claude/skills/client-generators/SKILL.md` — the shared authoring guide (the generator contract, the API model, the helper library). -The skills are yours to edit, like the generator: `--update` three-way merges your skill edits with the newer version, while a fresh eject or `--force` writes them as we ship them. -Beside the code, `/AGENTS.md` gets a short pointer to the skills, so the directory explains itself to a reader who opens it cold; anything you add outside its markers survives. + In both cases, the file imports the authoring toolkit from `@redocly/client-generator`. + A bundled generator also imports `logger` and `isPlainObject` from `@redocly/openapi-core`, which is a dependency of the toolkit. + If your package manager does not hoist dependencies, add `@redocly/openapi-core` explicitly. -Eject wires itself up: it adds `@redocly/client-generator` to your `devDependencies` if it isn't there and points your config at the file, where a path entry takes over the built-in name. +- `.claude/skills/-generator/SKILL.md` is the design of the generator, written as an agent skill. + The skill records the decisions that the code implements, and the loop to follow when you change the generator. + First state the change in the skill, then make the code match. + Coding agents load skills automatically, so your agent starts from the design and does not reverse-engineer the code. + +The first eject also writes `.claude/skills/client-generators/SKILL.md`, the shared authoring guide. +The guide describes the generator contract, the API model, and the helper library. +You can edit the skills, like the generator. +The `--update` option does a three-way merge of your skill edits with the newer version. +A fresh eject or `--force` writes the skills as we ship them. + +The command also writes a short pointer to the skills into `/AGENTS.md`, beside the code. +This pointer explains the directory to a reader who has no context. +The command keeps everything that you add outside the markers in that file. + +The eject command also configures your project. +It adds `@redocly/client-generator` to your `devDependencies` if the package is not there. +It also points your config at the ejected file, where a path entry replaces the built-in name. ```yaml client: @@ -50,15 +71,22 @@ client: - ./generators/python.mjs ``` -An ejected-unmodified generator produces byte-identical output to the built-in. +If you do not modify the ejected generator, its output is byte-identical to the output of the built-in generator. To roll back, delete the file and the config line. ## Update an ejected generator -`redocly eject-generator --update` merges the version shipped by your installed `@redocly/client-generator` into your copy. -The three-way merge uses the version recorded in the ejected file's header as the common ancestor, so nothing extra needs to be committed and there is no snapshot to keep in sync. -The two skills merge the same way, so design notes you added to them survive an update. -Conflicts arrive as standard `<<<<<<<` markers for you to resolve. +The `redocly eject-generator --update` command merges a newer version into your copy. +That version is the one shipped by your installed `@redocly/client-generator` package. +The three-way merge uses the version recorded in the header of the ejected file as the common ancestor. +Because of this, you do not have to commit extra files, and there is no snapshot to keep in sync. + +The command merges the two skills in the same way, so an update keeps the design notes that you added to them. +The command marks conflicts with standard `<<<<<<<` markers. +Resolve the conflicts manually. -Ejected generators keep working across CLI upgrades as long as the authoring contract they were written against is compatible. -The contract follows the `@redocly/client-generator` version: a breaking change bumps the major version (the minor, while the package is `0.x`), and a generator ejected from an incompatible version fails upfront with the version it expects, the version you have, and the `--update` command to reconcile them. +An ejected generator continues to operate across CLI upgrades if the authoring contract that it was written against stays compatible. +The contract follows the `@redocly/client-generator` version. +A breaking change increases the major version (the minor version, while the package is `0.x`). +A generator ejected from an incompatible version fails before it runs. +The error shows the version that the generator expects, the version that you have, and the `--update` command that aligns them. diff --git a/docs/@v2/commands/generate-client.md b/docs/@v2/commands/generate-client.md index c3a6dfeabc..49a80ad9a4 100644 --- a/docs/@v2/commands/generate-client.md +++ b/docs/@v2/commands/generate-client.md @@ -1,24 +1,32 @@ # `generate-client` {% admonition type="warning" name="Experimental" %} -`generate-client` is an experimental feature: its flags, generated output, configuration schema, and custom-generator API may change in any minor release until it's stable. -We'd love your feedback while we stabilize it. +`generate-client` is an experimental feature. +Its flags, generated output, configuration schema, and custom-generator API can change in any minor release until the feature is stable. +Send us your feedback while we stabilize the feature. {% /admonition %} ## Introduction The `generate-client` command generates a typed TypeScript client from an OpenAPI 3.x description. -Swagger 2.0 descriptions are also accepted and normalized to the 3.x shape before generation. -The description is validated first: unresolved `$ref`s or structural errors fail generation with the problems listed, independent of your lint configuration. +The command also accepts Swagger 2.0 descriptions and normalizes them to the 3.x shape before generation. +The command validates the description first. +If the description has unresolved `$ref`s or structural errors, the command stops the generation and lists the problems. +This validation does not depend on your lint configuration. -The generated client has zero runtime dependencies by default — it uses only web-standard APIs (`fetch`, `AbortController`, `URLSearchParams`), so it runs in browsers, Node, Bun, Deno, and edge runtimes. -By default it emits a single self-contained file with inline types and one async function per operation. +By default, the generated client has zero runtime dependencies. +The client uses only web-standard APIs (`fetch`, `AbortController`, `URLSearchParams`). +Because of this, the client runs in browsers, Node, Bun, Deno, and edge runtimes. +By default, the command writes one self-contained file with inline types and one async function for each operation. -The `` argument is a file path, a URL, or an [`apis:` alias](../configuration/index.md), resolved the same way as in other commands such as `bundle` and `lint`. -An alias, or a path matching an api's `root`, uses that api's `client` block and `clientOutput`; an unmatched path or URL uses the top-level `client` defaults. -With no argument, a client is generated for every api that declares a `client` block or a `clientOutput` (see [`client` configuration](../configuration/reference/client.md)). +The `` argument is a file path, a URL, or an [`apis:` alias](../configuration/index.md). +The command resolves the argument in the same way as other commands, for example `bundle` and `lint`. +An alias, or a path that matches the `root` of an api, uses the `client` block and the `clientOutput` of that api. +An unmatched path or URL uses the top-level `client` defaults. +If you give no argument, the command generates a client for each api that declares a `client` block or a `clientOutput` (see [`client` configuration](../configuration/reference/client.md)). -This page covers running the command; for the generated client's runtime API (auth, error handling, middleware, retries, and the add-on generators), see [Use the generated client](../guides/use-generated-client.md). +This page tells you how to run the command. +For the runtime API of the generated client (auth, error handling, middleware, retries, and the add-on generators), see [Use the generated client](../guides/use-generated-client.md). ## Usage @@ -33,34 +41,37 @@ redocly generate-client [--help] [--version] ## Options -| Option | Type | Description | -| ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `api` | string | OpenAPI description file path, URL, or an `apis:` alias. Omit it to generate for every api that has a `client` block or `clientOutput`. | -| `--output`, `-o` | string | Output path (must end in `.ts`); the entry file in multi-file modes. Defaults to the api's `clientOutput`, else `.client.ts` next to the configuration file. Single-API invocations only. | -| `--output-mode` | string | File layout. See [Choose an output mode](#choose-an-output-mode).
**Possible values:** `single`, `split`. Default value is `single`. | -| `--runtime` | string | Where the client's engine lives. See [Choose a runtime](#choose-a-runtime).
**Possible values:** `inline`, `package`. Default value is `inline`. | -| `--import-ext` | string | Extension in generated relative imports. See [Run with Node directly](../guides/use-generated-client.md#run-with-node-directly).
**Possible values:** `js` (the tsc/bundler convention), `ts` (for Node's built-in type stripping). Default value is `js`. | -| `--generator` | [string] | Generator to run: a built-in name, or a custom generator's path or package. Repeat the flag to run several. Default value is `sdk`. See [Generators](../guides/use-generated-client.md#generators) for the full list. | -| `--args-style` | string | How operation inputs are passed. See [Argument style](../guides/use-generated-client.md#argument-style).
**Possible values:** `flat`, `grouped`. Default value is `flat`. | -| `--error-mode` | string | How operations report HTTP errors. See [Error handling](../guides/use-generated-client.md#error-handling).
**Possible values:** `throw`, `result`. Default value is `throw`. | -| `--date-type` | string | Type of `date`/`date-time` fields; pair `Date` with the `transformers` generator.
**Possible values:** `string`, `Date`. Default value is `string`. | -| `--mock-data` | string | Data mode for the `mock` generator.
**Possible values:** `static` (deterministic literals), `faker` (`@faker-js/faker` calls). Default value is `static`. | -| `--mock-seed` | number | Seed for `faker`-mode mocks, for reproducible data. Ignored in `static` mode. | -| `--server-url` | string | Override the server URL included in the client as its default. Accepts an absolute (`https://api.example.com`) or relative (`/v1`) URL. Defaults to `servers[0].url`. The app can also repoint the client at runtime — `createClient({ serverUrl })` or `configure({ serverUrl })`, see [Authentication](../guides/use-generated-client.md#authentication) in the usage guide. | -| `--setup` | string | Path to a publisher setup module that gets included in the client — pre-configure defaults such as the server URL, retries, headers, and middleware, so a published SDK ships with them built in. See [Publisher defaults](../guides/customize-client-generation.md#publisher-defaults). | -| `--bin-name` | string | Command name the `cli` generator prints in help output and uses to derive its credential environment variables. Defaults to the output stem with non-word characters folded to `-`. | -| `--go-package` | string | Package clause of the `go` generator's output. Must be a valid Go package name (lowercase letters, digits, and `_`, not starting with a digit, not a keyword). Default value is `client`. | -| `--config` | string | Specify path to the [configuration file](#generate-from-the-configuration-file). | -| `--help` | boolean | Show help. | -| `--version` | boolean | Show version number. | +| Option | Type | Description | +| ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `api` | string | The file path to the OpenAPI description, a URL, or an `apis:` alias. Omit it to generate a client for each api that has a `client` block or `clientOutput`. | +| `--output`, `-o` | string | The output path (it must end in `.ts`). In multi-file modes, this is the entry file. Defaults to the `clientOutput` of the api, else `.client.ts` next to the configuration file. Use this option only when you generate one API. | +| `--output-mode` | string | The file layout. See [Choose an output mode](#choose-an-output-mode).
**Possible values:** `single`, `split`. Default value is `single`. | +| `--runtime` | string | The location of the client engine. See [Choose a runtime](#choose-a-runtime).
**Possible values:** `inline`, `package`. Default value is `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 value is `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 `sdk`. 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:** `flat`, `grouped`. Default value is `flat`. | +| `--error-mode` | string | Sets how operations report HTTP errors. See [Error handling](../guides/use-generated-client.md#error-handling).
**Possible values:** `throw`, `result`. Default value is `throw`. | +| `--date-type` | string | The type of the `date`/`date-time` fields. If you use `Date`, also use the `transformers` generator.
**Possible values:** `string`, `Date`. Default value is `string`. | +| `--mock-data` | string | The data mode for the `mock` generator.
**Possible values:** `static` (deterministic literals), `faker` (`@faker-js/faker` calls). Default value is `static`. | +| `--mock-seed` | number | The seed for `faker`-mode mocks. Use it to get reproducible data. The command ignores it in `static` mode. | +| `--server-url` | string | Overrides the default server URL in the client. The option accepts an absolute URL (`https://api.example.com`) or a relative URL (`/v1`). Defaults to `servers[0].url`. The app can also change the server URL at runtime with `createClient({ serverUrl })` or `configure({ serverUrl })`. See [Authentication](../guides/use-generated-client.md#authentication) in the usage guide. | +| `--setup` | string | The path to a publisher setup module that the command includes in the client. Use it to pre-configure defaults, for example the server URL, retries, headers, and middleware. A published SDK then contains these defaults. See [Publisher defaults](../guides/customize-client-generation.md#publisher-defaults). | +| `--bin-name` | string | The command name that the `cli` generator prints in the help output. The generator also uses it to derive the names of the credential environment variables. Defaults to the output stem with non-word characters converted to `-`. | +| `--go-package` | string | The package clause in the output of the `go` generator. It must be a valid Go package name (lowercase letters, digits, and `_`; it must not start with a digit or be a keyword). Default value is `client`. | +| `--config` | string | Specify the path to the [configuration file](#generate-from-the-configuration-file). | +| `--help` | boolean | Show help. | +| `--version` | boolean | Show version number. | ## Examples ### Generate from the configuration file -Instead of passing flags every time, keep the settings in `redocly.yaml` under a top-level `client` block and per-API `apis..client` / `clientOutput` — see the [`client` configuration reference](../configuration/reference/client.md) for the fields. +You do not have to pass flags each time. +Keep the settings in `redocly.yaml` under a top-level `client` block and per-API `apis..client` / `clientOutput`. +See the [`client` configuration reference](../configuration/reference/client.md) for the fields. CLI flags take precedence over the configuration. -Auto-pagination has no CLI flag; it's declared only as [`client.pagination`](../configuration/reference/client.md#pagination-object) configuration or the `x-redoclyPagination` operation extension. +Auto-pagination has no CLI flag. +Declare it only as the [`client.pagination`](../configuration/reference/client.md#pagination-object) configuration or the `x-redoclyPagination` operation extension. ```yaml client: @@ -79,7 +90,8 @@ redocly generate-client cafe # just the `cafe` api ### Generate from a file path or URL -An unmatched path or URL uses the top-level `client` defaults; `--output` names the entry file: +An unmatched path or URL uses the top-level `client` defaults. +The `--output` flag names the entry file: ```bash redocly generate-client openapi.yaml --output dist/client.ts @@ -87,10 +99,10 @@ redocly generate-client openapi.yaml --output dist/client.ts ### Choose an output mode -`--output-mode` controls how the client is split across files: +The `--output-mode` flag controls how the command splits the client into files: -- `single` (default) — one file (self-contained with the default `inline` runtime). -- `split` — two files: the schema types and type guards move to a sibling `.schemas.ts`, and the entry file re-exports them, so your imports are the same as in `single`. +- `single` (default): the command writes one file. The file is self-contained with the default `inline` runtime. +- `split`: the command writes two files. It puts the schema types and the type guards in a sibling file, `.schemas.ts`. The entry file re-exports them. Because of this, your imports are the same as in `single`. ```bash redocly generate-client openapi.yaml -o src/api/client.ts --output-mode split @@ -100,18 +112,19 @@ Both modes work with both runtimes. ### Choose a runtime -`--runtime` controls where the client's engine (request building, auth, retries, middleware, SSE) lives: +The `--runtime` flag controls the location of the client engine (request building, auth, retries, middleware, SSE): -- `inline` (default) — the runtime source is embedded in the generated output (only the parts your API needs): self-contained, zero runtime dependencies. -- `package` — the generated file imports the runtime from `@redocly/client-generator` and contains only the types, operation descriptors, and thin call wrappers. +- `inline` (default): the command embeds the runtime source in the generated output. It embeds only the parts that your API needs. The output is self-contained and has zero runtime dependencies. +- `package`: the generated file imports the runtime from `@redocly/client-generator`. The file contains only the types, the operation descriptors, and thin call wrappers. -Choose `package` when you want engine fixes to arrive via `npm update @redocly/client-generator` with no regeneration; the consuming app must then have that package installed as a regular dependency. -Your application code is identical in both modes. +Choose `package` if you want to get engine fixes with `npm update @redocly/client-generator` and no regeneration. +In this mode, the app that uses the client must install that package as a regular dependency. +Your application code is the same in both modes. See [Package runtime](../guides/use-generated-client.md#package-runtime) in the usage guide and the [`package-runtime` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/package-runtime). ## Resources -- [Use the generated client](../guides/use-generated-client.md) — the runtime API and the add-on generators. -- [`client` configuration](../configuration/reference/client.md) — the `redocly.yaml` `client` block. -- [Lint command](./lint.md) to validate your API description before generating a client. -- [Bundle command](./bundle.md) to combine a multi-file description into a single input file. +- [Use the generated client](../guides/use-generated-client.md): the runtime API and the add-on generators. +- [`client` configuration](../configuration/reference/client.md): the `redocly.yaml` `client` block. +- [Lint command](./lint.md): validate your API description before you generate a client. +- [Bundle command](./bundle.md): combine a multi-file description into one input file. diff --git a/docs/@v2/commands/index.md b/docs/@v2/commands/index.md index 93c3d48407..8688db9a70 100644 --- a/docs/@v2/commands/index.md +++ b/docs/@v2/commands/index.md @@ -9,22 +9,22 @@ Documentation commands: - [`preview`](preview.md) Start a local preview of a Redocly project with one of the product NPM packages. - [`translate`](translate.md) Generate translation keys for a Redocly Realm, Reef, or Revel project. - [`eject`](eject.md) Eject and modify components from the core theme in a Redocly Realm, Reef, or Revel project. -- [`build-docs`](build-docs.md) Build API description into an HTML file. +- [`build-docs`](build-docs.md) Build an API description into an HTML file. API management commands: -- [`bundle`](bundle.md) Bundle API description. +- [`bundle`](bundle.md) Bundle an API description. - [`generate-client`](generate-client.md) Generate a typed TypeScript client from an OpenAPI description [experimental feature]. -- [`eject-generator`](eject-generator.md) Vendor a built-in client generator into your repo as an editable file [experimental feature]. +- [`eject-generator`](eject-generator.md) Copy a built-in client generator into your repository as an editable file [experimental feature]. - [`join`](join.md) Join API descriptions [experimental feature]. - [`score`](score.md) Score an API for integration simplicity and AI agent readiness. -- [`split`](split.md) Split API description into a multi-file structure. +- [`split`](split.md) Split an API description into a multi-file structure. - [`stats`](stats.md) Gather statistics for a document. Linting commands: -- [`lint`](lint.md) Lint API description. -- [`check-config`](check-config.md) Lint Redocly configuration file. +- [`lint`](lint.md) Lint an API description. +- [`check-config`](check-config.md) Lint the Redocly configuration file. Testing commands: @@ -47,11 +47,13 @@ Supporting commands: ## Additional options -There are some parameters supported by all commands: +All commands support these parameters: -`--version` display the current version of `redocly`. +`--version` displays the current version of `redocly`. -`--help` display the command help, or the help for the subcommand if you used one. For example: +`--help` displays the help for the command. +If you used a subcommand, it displays the help for that subcommand. +For example: ```bash npx @redocly/cli@latest lint --help @@ -61,13 +63,15 @@ Try these with any of the other commands. ## Config file -Redocly CLI comes with one primary configuration file (`redocly.yaml`), also known as the Redocly configuration file. -This file defines all of the config options available to you, including the location of your files (for unbundling and bundling), and linting rules (for validation against the OpenAPI Specification). +Redocly CLI has one primary configuration file (`redocly.yaml`), also called the Redocly configuration file. +This file defines all of the configuration options available to you. +These options include the location of your files (for unbundling and bundling) and the linting rules (for validation against the OpenAPI Specification). -The Redocly configuration file must sit in your root directory. -If Redocly CLI finds `redocly.yaml` in the root directory, it uses the options set in that file when executing commands. +The Redocly configuration file must be in your root directory. +If Redocly CLI finds `redocly.yaml` in the root directory, it uses the options set in that file when it executes commands. -You can also specify a config file to most commands using `--config myconfig.yaml` as part of the command. For example: +For most commands, you can also specify a configuration file with `--config myconfig.yaml` as part of the command. +For example: ```bash npx @redocly/cli@latest lint --config redocly-official.yaml openapi.yaml diff --git a/docs/@v2/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md index 4aeabe009d..eb0c894a5b 100644 --- a/docs/@v2/configuration/reference/client.md +++ b/docs/@v2/configuration/reference/client.md @@ -2,70 +2,83 @@ ## Introduction -The `client` configuration provides settings for the [`generate-client`](../../commands/generate-client.md) command. -The block can be used at the root of the configuration file, where it holds defaults, and inside an [API-specific section](./apis.md) (`apis..client`), where it overrides the root block for the specific API. +The `client` configuration contains the settings for the [`generate-client`](../../commands/generate-client.md) command. +You can put the block at the root of the configuration file, where it holds the defaults. +You can also put it inside an [API-specific section](./apis.md) (`apis..client`), where it overrides the root block for that API. The input and output are not part of the `client` block: -- **input** — `apis..root`, or a path or alias passed on the command line. -- **output** — `apis..clientOutput`; when omitted it defaults to `.client.ts` next to the configuration file. - The `--output` flag overrides it for single-API invocations. +- **input** — `apis..root`, or a path or alias that you give on the command line. +- **output** — `apis..clientOutput`. + If you omit it, the default is `.client.ts` next to the configuration file. + The `--output` flag overrides it when you generate one API. ## Options -Each scalar option mirrors the matching CLI flag and shares its default — see the [command options](../../commands/generate-client.md#options) for the full description of each value. -The `pagination` option is config-only — a structured, durable contract that belongs in versioned configuration rather than a shell string. -For runs without a configuration file, declare pagination per operation with the `x-redoclyPagination` extension in the description, or pass `pagination` to the programmatic `generateClient(...)`. - -| Option | Type | Description | -| ---------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `generators` | [string] | Generators to run, in order. Each entry is a built-in name (`sdk`, `zod`, `tanstack-query` — or its `-vue`/`-svelte`/`-solid` variants — `swr`, `mock`, `transformers`, `cli`, `cli-docs`, `python`, `go`, `php`) or a custom generator's path or package name. | -| `outputMode` | string | File layout: `single` or `split`. TypeScript output only — the `python`, `go`, and `php` SDKs always emit one self-contained file. | -| `runtime` | string | Runtime distribution: `inline` or `package`. TypeScript output only — the `python`, `go`, and `php` SDKs always embed their runtime. | -| `importExt` | string | Extension in generated relative imports: `js` (default, for tsc and bundlers) or `ts` (for Node's built-in type stripping). TypeScript output only. | -| `argsStyle` | string | How operation inputs are passed: `flat` or `grouped`. 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 are throw-only (their language idiom IS the error mode) and reject `result`. | -| `dateType` | string | Type of `date`/`date-time` fields: `string` or `Date`. Every language honors it — `Date` in TypeScript, `datetime`/`date` in Python, `time.Time`/`Date` in Go, `DateTimeImmutable` in PHP. | -| `mockData` | string | Data mode for the `mock` generator: `static` or `faker`. | -| `mockSeed` | number | Seed for `faker`-mode mocks. | -| `queryKeyPrefix` | string | Leading element for every `tanstack-query` query/mutation key — namespaces the cache when several generated APIs share one QueryClient. Config-only, no flag. | -| `codeSamples` | boolean | Emit `.code-samples.yaml` — an OpenAPI Overlay adding per-operation `x-codeSamples` collected from every selected generator that implements `sample()`. Config-only, no flag. | -| `serverUrl` | string | Server URL included in the client as its default; falls back to `servers[0].url`. | -| `goPackage` | string | Package clause for the `go` generator's output. Must be a valid Go package name (lowercase letters, digits, and `_`, not starting with a digit, not a keyword) — an invalid value fails generation instead of emitting a file Go can't compile. Default `client`. | -| `binName` | string | Command name the `cli` generator uses in help output and to derive its credential environment variables. Defaults to the output stem, sanitized. | -| `cliOutput` | string | Path of a composed CLI entry spanning every api that emits a cli module — the `cli` generator by name, ejected, or pulled in as a prerequisite — one binary, each api addressed by its alias, with `__*` credential variables. Top-level `client` block only; see [Compose and extend the CLI](../../guides/use-generated-client.md#compose-and-extend-the-cli). | -| `options` | object | Per-generator options, keyed by generator name — validated against the schema a generator declares. See [Custom generators](../../guides/customize-client-generation.md#custom-generators). | -| `setup` | string | Path to a publisher setup module that gets included in the client — pre-configures defaults such as the server URL, retries, headers, and middleware. See [Publisher defaults](../../guides/customize-client-generation.md#publisher-defaults). | -| `pagination` | [Pagination object](#pagination-object) | Declares how the API paginates, so paginated operations gain typed `.pages()`/`.items()` async iterators. | +Each scalar option matches the related CLI flag and has the same default. +See the [command options](../../commands/generate-client.md#options) for the full description of each value. +The `pagination` option is available only in the configuration file. +It is a structured, durable contract that belongs in versioned configuration, not in a shell string. +If you run without a configuration file, declare pagination for each operation with the `x-redoclyPagination` extension in the description. +As an alternative, pass `pagination` to the programmatic `generateClient(...)`. + +| Option | Type | Description | +| ---------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `generators` | [string] | The generators to run, in order. Each entry is a built-in name (`sdk`, `zod`, `tanstack-query` or its `-vue`/`-svelte`/`-solid` variants, `swr`, `mock`, `transformers`, `cli`, `cli-docs`, `python`, `go`, `php`), or the path or package name of a custom generator. | +| `outputMode` | string | The file layout: `single` or `split`. This option applies to TypeScript output only. The `python`, `go`, and `php` SDKs always emit one self-contained file. | +| `runtime` | string | The runtime distribution: `inline` or `package`. This option applies to TypeScript output only. The `python`, `go`, and `php` SDKs always embed their runtime. | +| `importExt` | string | The extension in generated relative imports: `js` (default, for tsc and bundlers) or `ts` (for Node's built-in type stripping). This option applies to TypeScript output only. | +| `argsStyle` | string | How the client receives operation inputs: `flat` or `grouped`. This option applies to TypeScript output only. Each language SDK follows its own idiom (keyword arguments, named arguments, a params struct). | +| `errorMode` | string | How operations report HTTP errors: `throw` or `result`. The `python` SDK implements both. The `go` and `php` SDKs support only `throw`, because that is the language idiom, and they reject `result`. | +| `dateType` | string | The type of `date`/`date-time` fields: `string` or `Date`. Every language applies it: `Date` in TypeScript, `datetime`/`date` in Python, `time.Time`/`Date` in Go, `DateTimeImmutable` in PHP. | +| `mockData` | string | The data mode for the `mock` generator: `static` or `faker`. | +| `mockSeed` | number | The seed for mocks in `faker` mode. | +| `queryKeyPrefix` | string | The first element of every `tanstack-query` query key and mutation key. It separates the cache entries when several generated APIs share one QueryClient. This option is available only in the configuration file and has no flag. | +| `codeSamples` | boolean | Emit `.code-samples.yaml`. This file is an OpenAPI Overlay that adds `x-codeSamples` to each operation. The samples come from each selected generator that implements `sample()`. This option is available only in the configuration file and has no flag. | +| `serverUrl` | string | The server URL that the client includes as its default. If you do not set it, the client uses `servers[0].url`. | +| `goPackage` | string | The package clause for the output of the `go` generator. The value must be a valid Go package name: lowercase letters, digits, and `_`, with no digit at the start, and not a keyword. An invalid value stops generation, so the generator does not emit a file that Go cannot compile. Default `client`. | +| `binName` | string | The command name that the `cli` generator shows in the help output and uses to derive its credential environment variables. The default is the output stem, sanitized. | +| `cliOutput` | string | The path of a composed CLI entry. The entry includes every api that emits a cli module: from the `cli` generator by name, ejected, or included as a prerequisite. The result is one binary. You address each api by its alias, and each api has `__*` credential variables. This option is available only in the top-level `client` block. See [Compose and extend the CLI](../../guides/use-generated-client.md#compose-and-extend-the-cli). | +| `options` | object | Options for each generator, keyed by generator name. The command validates each entry against the schema that the generator declares. See [Custom generators](../../guides/customize-client-generation.md#custom-generators). | +| `setup` | string | The path to a publisher setup module that the client includes. The module sets defaults such as the server URL, retries, headers, and middleware. See [Publisher defaults](../../guides/customize-client-generation.md#publisher-defaults). | +| `pagination` | [Pagination object](#pagination-object) | Declares how the API paginates. Paginated operations then get typed `.pages()`/`.items()` async iterators. | ### Pagination object -The `pagination` block is an optional convention rule (the rule fields below, applied to every operation it structurally fits when `style` is set), plus per-operation `operations` overrides and an `exclude` list. -See [Pagination in the usage guide](../../guides/use-generated-client.md#pagination) for how the generated iterators behave. - -| Option | Type | Description | -| ------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `style` | string | How the iterator advances: `cursor` (follow a response cursor), `offset` (advance an offset by each page's item count), `page` (increment a page number), or `link` (follow the response's RFC 8288 `Link` header `rel="next"` — no advance parameter; a convention rule fits only operations whose response documents a `Link` header). | -| `cursorParam` | string | The query parameter that receives the cursor. **REQUIRED** for the `cursor` style. | -| `nextCursor` | string | JSON pointer (RFC 6901, starts with `/`) to the next cursor in the response. **REQUIRED** for the `cursor` style. | -| `hasMore` | string | Optional (`cursor` style): JSON pointer to a boolean "more pages" flag — iteration stops when it resolves to `false`, for APIs whose cursor stays non-null on the last page. | -| `offsetParam` | string | The query parameter the iterator advances. **REQUIRED** for the `offset` and `page` styles. | -| `limitParam` | string | Optional page-size query parameter for any style; recorded for tooling — the iterator never sets it. | -| `items` | string | **REQUIRED**. JSON pointer to the page's item array in the response; use `''` when the response body is the item array itself. | -| `exclude` | [string] | operationIds that no source may paginate; wins over overrides, extensions, and the convention. | -| `operations` | map of operationId → rule | Per-operation rules taking the same fields as the convention; each entry beats the description's `x-redoclyPagination` and the convention. | - -The rules are verified at generate time: the advance parameter must be a declared query parameter of the right type (string for `cursor`, numeric for `offset` and `page`), and the JSON pointers must resolve in the operation's JSON success-response schema, with `items` landing on an array and `hasMore` on a boolean. -A convention that doesn't fit an operation skips it; an explicit rule that doesn't fit fails generation. -The `x-redoclyPagination` operation extension in the API description takes the same rule fields. -Per operation, precedence is `operations[id]`, then `x-redoclyPagination`, then the convention. +The `pagination` block is an optional convention rule, plus `operations` overrides for single operations and an `exclude` list. +The convention rule uses the rule fields below. +When you set `style`, the rule applies to each operation that it structurally fits. +See [Pagination in the usage guide](../../guides/use-generated-client.md#pagination) to learn how the generated iterators behave. + +| Option | Type | Description | +| ------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `style` | string | How the iterator advances: `cursor` (follow a response cursor), `offset` (advance an offset by the item count of each page), `page` (increment a page number), or `link` (follow the RFC 8288 `Link` header `rel="next"` in the response). The `link` style has no advance parameter. As a convention rule, `link` fits only the operations whose response documents a `Link` header. | +| `cursorParam` | string | The query parameter that receives the cursor. **REQUIRED** for the `cursor` style. | +| `nextCursor` | string | The JSON pointer (RFC 6901, starts with `/`) to the next cursor in the response. **REQUIRED** for the `cursor` style. | +| `hasMore` | string | Optional (`cursor` style): the JSON pointer to a boolean "more pages" flag. Iteration stops when the flag resolves to `false`. Use it for APIs whose cursor stays non-null on the last page. | +| `offsetParam` | string | The query parameter that the iterator advances. **REQUIRED** for the `offset` and `page` styles. | +| `limitParam` | string | Optional: the page-size query parameter for any style. The generator records it for tooling. The iterator never sets it. | +| `items` | string | **REQUIRED**. The JSON pointer to the item array of the page in the response. Use `''` if the response body is the item array itself. | +| `exclude` | [string] | The operationIds that no source may paginate. This list wins over overrides, extensions, and the convention. | +| `operations` | map of operationId → rule | Rules for single operations, with the same fields as the convention. Each entry overrides the `x-redoclyPagination` extension in the description and the convention. | + +The generator verifies the rules at generate time. +The advance parameter must be a declared query parameter of the correct type: string for `cursor`, numeric for `offset` and `page`. +The JSON pointers must resolve in the JSON success-response schema of the operation. +The `items` pointer must point to an array, and the `hasMore` pointer must point to a boolean. +If the convention does not fit an operation, the generator skips that operation. +If an explicit rule does not fit, generation fails. +The `x-redoclyPagination` operation extension in the API description uses the same rule fields. +For each operation, the precedence is `operations[id]`, then `x-redoclyPagination`, then the convention. ## Examples ### Configure defaults with a per-API override -An API with its own `client` block uses that block in place of the top-level one; the top-level block applies to APIs without one. -A file-path invocation matching no `apis:` entry uses the top-level `client`, and CLI flags take precedence over the resolved configuration. +An API with its own `client` block uses that block instead of the top-level block. +The top-level block applies to APIs without their own block. +A file-path invocation that matches no `apis:` entry uses the top-level `client`. +CLI flags override the resolved configuration. ```yaml client: @@ -88,7 +101,7 @@ apis: ### Declare pagination -Declare the convention once, with per-operation overrides and exclusions: +Declare the convention one time, with overrides and exclusions for single operations: ```yaml client: @@ -106,13 +119,14 @@ client: items: /data ``` -For code-level control — including registering [custom generators](../../guides/customize-client-generation.md#custom-generators) inline — use the programmatic `generateClient(...)` API instead. +For code-level control, use the programmatic `generateClient(...)` API instead. +With this API, you can also register [custom generators](../../guides/customize-client-generation.md#custom-generators) inline. ## Related options -- [apis](./apis.md) settings define each API's root document, output, and per-API overrides. +- The [apis](./apis.md) settings define the root document, the output, and the overrides for each API. ## Resources -- [`generate-client` command](../../commands/generate-client.md) — flags, output modes, and invocation. -- [Use the generated client](../../guides/use-generated-client.md) — the runtime API (auth, retries, middleware, extra generators). +- [`generate-client` command](../../commands/generate-client.md): flags, output modes, and invocation. +- [Use the generated client](../../guides/use-generated-client.md): the runtime API (auth, retries, middleware, extra generators). diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index 7a78490492..55e32f1dbe 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -1,15 +1,21 @@ # Customize client generation -How to shape what [`generate-client`](../commands/generate-client.md) produces — pre-configured publisher defaults and custom generators. -This page is for the person who **runs the generator** (an SDK publisher, a platform team); for consuming the generated client, see [Use the generated client](./use-generated-client.md). +This page tells you how to control the output of [`generate-client`](../commands/generate-client.md). +It covers pre-configured publisher defaults and custom generators. +This page is for the person who **runs the generator**, for example an SDK publisher or a platform team. +To use the generated client, see [Use the generated client](./use-generated-client.md). ## Publisher defaults -Middleware and configuration are normally composed by the [consumer](./use-generated-client.md#middleware). -If you **publish an SDK** you can pre-configure the client at generation time with `--setup `: defaults such as the server URL, retries, headers, and middleware are included in the generated client, so the SDK ships with them built in. -Setup changes the client's built-in _behavior_; it emits no extra file — to derive additional artifacts from the description, use [generators](./use-generated-client.md#generators) instead. +The [consumer](./use-generated-client.md#middleware) normally composes middleware and configuration. +If you **publish an SDK**, you can pre-configure the client at generation time with `--setup `. +The generated client then includes defaults such as the server URL, retries, headers, and middleware. +The SDK includes these defaults when you publish it. +Setup changes the client's built-in _behavior_ and writes no extra file. +To make more artifacts from the description, use [generators](./use-generated-client.md#generators) instead. -A setup module is a plain file that default-exports a `{ config, middleware }` object — no imports required: +A setup module is a plain file with a default export of a `{ config, middleware }` object. +It does not need imports: ```ts // client-setup.ts @@ -29,9 +35,14 @@ export default { redocly generate-client openapi.yaml --output src/api/client.ts --setup ./client-setup.ts ``` -Inclusion is a generation-time transform: only the setup expression lands in the client, so an `inline` client stays zero-dependency, and the included block is typed against the client's own contract in the generated file — a shape mistake fails the consumer's `tsc`. +Inclusion is a generation-time transform. +Only the setup expression goes into the client, so an `inline` client keeps zero dependencies. +The generated file types the included block against the client's own contract. +A shape mistake causes an error in the consumer's `tsc`. -For editor autocomplete while authoring, optionally wrap the object in `defineClientSetup` — a typing-only helper, stripped at generation time, identical in both runtimes: +To get editor autocomplete when you write the setup, you can wrap the object in `defineClientSetup`. +This helper only supplies types, and generation removes it. +The helper is identical in both runtimes: ```ts // client-setup.ts — the same setup, typed while editing @@ -50,46 +61,72 @@ export default defineClientSetup({ ``` The pre-configured block runs before the consumer's own setup. -**Config values** layer lowest to highest — later always wins, so a consumer overrides a pre-configured default: +**Config values** apply in layers, from lowest to highest. +A later value always wins, so a consumer overrides a pre-configured default: 1. The description's defaults (for example `servers[0].url`). 2. The publisher setup. 3. The app's `configure()`. -**Middleware composes** instead (publisher middleware first, then the consumer's). -Express un-bypassable behavior as middleware, not a custom `fetch`. -A setup file may import **only** from `@redocly/client-generator`. +**Middleware composes** instead: the publisher middleware runs first, then the consumer's middleware. +To make a behavior that the consumer cannot bypass, use middleware, not a custom `fetch`. +A setup file can import **only** from `@redocly/client-generator`. See the [`baked-setup` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/baked-setup). ## Eject -The fastest path to a customized generator is -[`redocly eject-generator `](../commands/eject-generator.md): it vendors any built-in generator into `./generators/` as an editable file you own. -An ejected-unmodified generator produces byte-identical output, and the path entry takes over the built-in name — regeneration survives every customization. +The quickest method to get a customized generator is +[`redocly eject-generator `](../commands/eject-generator.md). +The command copies any built-in generator into `./generators/` as an editable file that you own. +An ejected generator with no changes produces byte-identical output. +The path entry replaces the built-in name, so regeneration continues to work after each customization. [`--update`](../commands/eject-generator.md#update-an-ejected-generator) merges later built-in versions into your copy. -Eject also writes the generator's design as an agent skill (`.claude/skills/-generator/SKILL.md`) plus the shared authoring skill. -Your agent treats the design as the source of truth: state the change there first, then make the code match — and never hand-edit generated output, only the generator. +The eject command also writes the generator's design as an agent skill (`.claude/skills/-generator/SKILL.md`). +It writes the shared authoring skill too. +Your agent uses the design as the source of truth. +First, state the change in the design. +Then make the code agree with the design. +Do not edit the generated output by hand; edit only the generator. ## Custom generators The built-in generators cover common targets. -For anything else derived from the same description (validators in another library, a permissions map, a house-style SDK), write a **custom generator**: it reads the same API model the built-ins consume, so its output never drifts from the description. -A generator adds artifacts _next to_ the client — it doesn't change the generated client's behavior; for that, use [publisher defaults](#publisher-defaults) or let the consumer compose [middleware](./use-generated-client.md#middleware). - -A generator is `{ name, run }` (plus optional compatibility metadata); author it with `defineGenerator` from the package root. -The output is text, so a generator can emit **any language** — Python models, a Go client, a permissions matrix. -Emitted file paths must stay inside the `--output` directory — subdirectories are fine, escapes are rejected. +For other artifacts from the same description, write a **custom generator**. +Examples are validators in another library, a permissions map, or an SDK in your house style. +A custom generator reads the same API model as the built-in generators. +Because of this, its output always agrees with the description. + +A generator adds artifacts _next to_ the client. +It does not change the behavior of the generated client. +To change the behavior, use [publisher defaults](#publisher-defaults) or let the consumer compose [middleware](./use-generated-client.md#middleware). + +A generator is a `{ name, run }` object, with optional compatibility metadata. +Write it with `defineGenerator` from the package root. +The output is text, so a generator can emit **any language**. +Examples are Python models, a Go client, or a permissions matrix. +Emitted file paths must stay inside the `--output` directory. +Subdirectories are permitted, but the CLI rejects paths that escape the directory. **Compatibility follows the `@redocly/client-generator` version.** -The API model and the helper library are the generator contract, and it changes under semver: a breaking change bumps the major version (the minor, while the package is `0.x`). -Declare the version you authored against with `requiresGenerator: '^1.2.0'`, and an incompatible CLI fails upfront — naming the version it has, the version you need, and the upgrade — instead of feeding your generator a model shape it doesn't expect. -Ejected generators record it for you. -The accepted range forms are `^1.2.0`, `~1.2.0`, `>=1.2.0`, and an exact `1.2.0`; anything else is rejected as unreadable rather than guessed at. -Omitting `requiresGenerator` means "assume current", which is fine while you iterate. -Set it before the generator outlives the CLI it was written against — a shared repository, a published package, anything regenerated by CI — since the failure it prevents (a changed model shape) otherwise shows up as strange output rather than an error. +The API model and the helper library are the generator contract, and the contract changes under semver. +A breaking change increases the major version (the minor version, while the package is `0.x`). +Declare the version that you wrote against with `requiresGenerator: '^1.2.0'`. +An incompatible CLI then fails immediately and does not give your generator a model shape it does not expect. +The error names the version the CLI has, the version you need, and the upgrade. + +Ejected generators record the version for you. +The accepted range forms are `^1.2.0`, `~1.2.0`, `>=1.2.0`, and an exact `1.2.0`. +The CLI rejects other forms as unreadable and does not guess. + +If you omit `requiresGenerator`, the CLI assumes the current version. +This is acceptable while you iterate. +Set the version before the generator stays in use longer than the CLI it was written for. +Examples are a shared repository, a published package, and output that CI regenerates. +Without the version, a changed model shape causes incorrect output, not an error. -**A generator can declare its own options** with a JSON Schema, so publishers configure it the way they configure the built-ins: +**A generator can declare its own options** with a JSON Schema. +Publishers then configure it in the same way as the built-in generators: ```js export default defineGenerator({ @@ -118,44 +155,56 @@ client: groupBy: path ``` -The schema covers what configuration needs, not all of JSON Schema: -a top-level `type: 'object'` with `properties`, `required`, and `additionalProperties`, -where each property is a scalar (`string`, `number`, `boolean`), an `enum`, or an array of scalars (`{ type: 'array', items: { type: 'string' } }`). -Each property may carry a `default` and a `description`. +The schema covers what configuration needs, not all of JSON Schema. +It permits a top-level `type: 'object'` with `properties`, `required`, and `additionalProperties`. +Each property is a scalar (`string`, `number`, `boolean`), an `enum`, or an array of scalars (`{ type: 'array', items: { type: 'string' } }`). +Each property can have a `default` and a `description`. -Validation runs once per generator before any file is written: -an unknown key, a value of the wrong type, a value outside an `enum`, or a missing `required` key fails generation with the generator's name and the offending key. -Unknown keys are rejected unless the schema sets `additionalProperties: true`. -`run` receives `options` with defaults applied, so a generator reads its options without re-checking them. -Setting `options` for a selected generator that declares no schema warns — the entry would otherwise be silently ignored. - -### Language-neutral helpers +Validation runs one time per generator before the CLI writes any file. +An unknown key, a value of an incorrect type, a value outside an `enum`, or a missing `required` key stops generation. +The error shows the generator's name and the incorrect key. +The CLI rejects unknown keys unless the schema sets `additionalProperties: true`. +`run` receives `options` with the defaults applied, so a generator reads its options without more checks. -The package root exports pure helpers over the API model that cover the cross-language variance points, so a generator in any output language never re-implements schema semantics: +If you set `options` for a selected generator that declares no schema, the CLI shows a warning. +Without the warning, the CLI would ignore the entry with no message. -| 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 (sealed hierarchy, type switch, `Union` — each language renders its own). | -| `isNullable(schema)` / `unwrapNullable(schema)` | Detect and strip `null` union members (`Optional[T]`, pointers, `Option`). | -| `enumValues(schema)` | Values plus SCREAMING_SNAKE member-name suggestions. | -| `casing` / `identifierFor(name, opts)` | camel/pascal/snake/screaming casing; keyword-safe identifiers (`RESERVED_WORDS.python/go/typescript` shipped, pass your own set). | -| `Printer` | Indentation-aware text builder — no manual whitespace bookkeeping. | -| `docText(description)` | Description text 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 applying to an operation (per-op config > `x-redoclyPagination` > fitting convention), normalized. | -| `NotSupportedError` | Throw it to reject an option the generator can't honor — the CLI prints the message as a user error, not a crash. | - -These helpers plus `Printer` are the ONE way to author a generator, in any output language. -Nothing in the authoring path depends on the `typescript` package, so a generator also runs in the browser or any other embedded host. -The only part of `generate-client` that parses TypeScript is baking a `--setup` module, which is why `typescript` is an optional peer dependency: install it if you use that flag, and skip it otherwise. +### Language-neutral helpers -`redocly eject-generator ` writes this guidance into your repo as an agent skill, so your coding agent has the contract, the model reference, and this helper table without being told. +The package root exports pure helpers over the API model. +The helpers cover the points where output languages differ. +Because of this, a generator in any output language does not implement schema semantics again: + +| Helper | Use | +| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `flattenAllOf(schema, model)` | Gives the merged property view of `allOf` compositions. Languages without intersection types render this. | +| `discriminatorCases(schema, model)` | Gives a `{ property, cases }` dispatch table for discriminated unions. Each language renders its own form: a sealed hierarchy, a type switch, or a `Union`. | +| `isNullable(schema)` / `unwrapNullable(schema)` | Finds and removes `null` union members (`Optional[T]`, pointers, `Option`). | +| `enumValues(schema)` | Gives the values plus SCREAMING_SNAKE member-name suggestions. | +| `casing` / `identifierFor(name, opts)` | Gives camel/pascal/snake/screaming casing and keyword-safe identifiers (`RESERVED_WORDS.python/go/typescript` are included, or pass your own set). | +| `Printer` | A text builder that manages indentation. You do not manage whitespace manually. | +| `docText(description)` | Gives the description text as trimmed lines for any comment syntax. | +| `schemaAtPointer(schema, pointer, model)` | Resolves an RFC 6901 JSON pointer over a schema, through refs and `allOf`. Example: a pagination `items` pointer to its element type. | +| `paginationRuleFor(op, config)` | Gives the normalized pagination rule that applies to an operation (per-op config > `x-redoclyPagination` > fitting convention). | +| `NotSupportedError` | Throw it to reject an option that the generator cannot obey. The CLI prints the message as a user error, not a crash. | + +These helpers plus `Printer` are the ONE way to write a generator, in any output language. +No part of the authoring path depends on the `typescript` package. +Because of this, a generator also runs in the browser or in another embedded host. +Only one step of `generate-client` parses TypeScript: the step that bakes a `--setup` module. +For this reason, `typescript` is an optional peer dependency. +Install it if you use that flag, and do not install it otherwise. + +`redocly eject-generator ` writes this guidance into your repository as an agent skill. +Your coding agent then has the contract, the model reference, and this helper table without instructions from you. ### TypeScript artifacts -TypeScript is just another output language: the `@redocly/client-generator/generate` entry exports the TypeScript-specific renderers, kept off the package root so a `runtime: 'package'` client's import graph never carries the generation toolkit. -`tsType` is the schema→type renderer the built-in sdk itself uses, so the mapping (refs, arrays, unions, formats, parenthesization) matches the generated client exactly: +TypeScript is one more output language. +The `@redocly/client-generator/generate` entry exports the TypeScript-specific renderers. +These renderers are not on the package root, so the import graph of a `runtime: 'package'` client never includes the generation toolkit. +`tsType` is the schema-to-type renderer that the built-in sdk itself uses. +Because of this, the mapping (refs, arrays, unions, formats, parenthesization) is exactly the same as in the generated client: ```js import { tsType } from '@redocly/client-generator/generate'; @@ -180,10 +229,11 @@ export default { }; ``` -The package root exports `tsType`, `tsJsdoc`, `codeLiteral`, `operationSignature`, and `pascalCase` alongside the model (IR) types and the neutral helpers — one import path for everything. -For a trivial artifact, returning a plain string as `content` works too. +The `@redocly/client-generator/generate` entry exports `tsType`, `tsJsdoc`, `codeLiteral`, `operationSignature`, and `pascalCase`. +The package root exports the model (IR) types and the neutral helpers. +For a simple artifact, you can also return a plain string as `content`. -Select a generator in `redocly.yaml` by path or package name: +Select a generator in `redocly.yaml` by path or by package name: ```yaml apis: @@ -197,7 +247,7 @@ apis: - '@acme/openapi-valibot' # published package ``` -Or register one **inline** with the programmatic API and select it by name: +Or register a generator **inline** with the programmatic API and select it by name: ```ts import { generateClient } from '@redocly/client-generator'; @@ -213,15 +263,26 @@ await generateClient({ ### Code samples for docs -A generator that knows how to call an operation can also document it: implement the optional `sample(operation, ctx)` hook to return one idiomatic snippet (`{ lang, label, source }`) per operation. -With `codeSamples: true` in the `client` block, generation collects every selected generator's samples into `.code-samples.yaml` — an [OpenAPI Overlay](https://spec.openapis.org/overlay/latest.html) adding `x-codeSamples` per operation, ready for docs tooling to apply. -The built-in `sdk` generator ships the TypeScript reference implementation, so enabling the flag alone gives your Redoc docs per-operation TypeScript examples that never drift from the SDK. +A generator that can call an operation can also document the operation. +Implement the optional `sample(operation, ctx)` hook to return one idiomatic snippet (`{ lang, label, source }`) for each operation. +With `codeSamples: true` in the `client` block, generation collects the samples of every selected generator into `.code-samples.yaml`. +This file is an [OpenAPI Overlay](https://spec.openapis.org/overlay/latest.html) that adds `x-codeSamples` to each operation. +Docs tooling can apply the file. + +The built-in `sdk` generator includes the TypeScript reference implementation. +If you only set the flag, your Redoc docs get a TypeScript example for each operation. +These examples always agree with the SDK. + +Import-specifier generators execute at generation time. +They have the same trust level as any installed dependency that you run. -Import-specifier generators execute at generation time — they carry the same trust level as any installed dependency you run. -See the [`typescript-types-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/typescript-types-generator) for the runnable `tsType`-based plugin (including type-importing referenced schemas), the [`custom-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/custom-generator) for a minimal string-building one, and the [`nested-facade` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/nested-facade) for a realistic one that derives an `api..` facade from the description's tags. +See the [`typescript-types-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/typescript-types-generator) for the runnable plugin based on `tsType`. +It also shows how to type-import referenced schemas. +See the [`custom-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/custom-generator) for a minimal generator that builds strings. +See the [`nested-facade` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/nested-facade) for a realistic generator that derives an `api..` facade from the description's tags. ## Resources -- [`generate-client` command](../commands/generate-client.md) — flags, output modes, and invocation. -- [`client` configuration](../configuration/reference/client.md) — the `redocly.yaml` `client` block. -- [Use the generated client](./use-generated-client.md) — the consumer-side guide. +- [`generate-client` command](../commands/generate-client.md): flags, output modes, and invocation. +- [`client` configuration](../configuration/reference/client.md): the `redocly.yaml` `client` block. +- [Use the generated client](./use-generated-client.md): the guide for the consumer. diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index 816c68d8c5..b91762e69c 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -1,39 +1,50 @@ # Use the generated client -How to consume the TypeScript client produced by [`generate-client`](../commands/generate-client.md): authentication, argument styles, error handling, middleware, retries, and the optional add-on generators. -For invoking the command itself (flags, output modes, config), see the [`generate-client` command reference](../commands/generate-client.md). -To shape what gets generated — publisher defaults, custom generators — see [Customize client generation](./customize-client-generation.md). +This guide tells you how to use the TypeScript client that [`generate-client`](../commands/generate-client.md) produces. +It covers authentication, argument styles, error handling, middleware, retries, and the optional add-on generators. +For the command itself (flags, output modes, config), see the [`generate-client` command reference](../commands/generate-client.md). +To change what the command generates (publisher defaults, custom generators), see [Customize client generation](./customize-client-generation.md). ## Generators -`--generator` selects what to emit (default `sdk`). -Each non-`sdk` generator adds a standalone sibling module next to the client; the client itself never imports it, so an add-on never adds a dependency to the client. -Incompatible selections fail fast with an explanation. - -| Generator | Emits | App peer dependency | -| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | -| `sdk` | The typed client (default). | none | -| `zod` | `.zod.ts` — [Zod](https://zod.dev) schemas + [validation middleware](#runtime-validation). | `zod` `^3.23 \|\| ^4` | -| `tanstack-query` | `.tanstack.ts` — [TanStack Query](https://tanstack.com/query) v5 [factories](#tanstack-query-factories), including `InfiniteOptions` for paginated operations. React by default; `tanstack-query-vue`/`-svelte`/`-solid` switch the adapter import. | `@tanstack/-query` `^5` | -| `swr` | `.swr.ts` — [SWR](https://swr.vercel.app) hooks. | `swr` `^2` | -| `mock` | `.mocks.ts` — [MSW](https://mswjs.io) v2 handlers + `create` factories. | `msw` `^2` (+ `@faker-js/faker` for `--mock-data faker`) | -| `transformers` | `.transformers.ts` — `transform` functions that parse wire dates to `Date`. | none | -| `cli` | `.cli.ts` — a bin-ready [command-line interface](#generated-cli) over the client: typed flags, `--json` bodies, env auth, `--page-all`. | none | -| `cli-docs` | `.cli.md` — a Markdown [reference for the generated CLI](#cli-reference-docs): every command, flag, exit code, and credential variable. | none | +The `--generator` option selects the output (default `sdk`). +Each non-`sdk` generator adds a standalone module next to the client. +The client never imports this module. +Because of this, an add-on never adds a dependency to the client. +Incompatible selections fail immediately with an explanation. + +| Generator | Emits | App peer dependency | +| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | +| `sdk` | The typed client (default). | none | +| `zod` | `.zod.ts`: [Zod](https://zod.dev) schemas and [validation middleware](#runtime-validation). | `zod` `^3.23 \|\| ^4` | +| `tanstack-query` | `.tanstack.ts`: [TanStack Query](https://tanstack.com/query) v5 [factories](#tanstack-query-factories), with `InfiniteOptions` for paginated operations. React by default; `tanstack-query-vue`/`-svelte`/`-solid` change the adapter import. | `@tanstack/-query` `^5` | +| `swr` | `.swr.ts`: [SWR](https://swr.vercel.app) hooks. | `swr` `^2` | +| `mock` | `.mocks.ts`: [MSW](https://mswjs.io) v2 handlers and `create` factories. | `msw` `^2` (+ `@faker-js/faker` for `--mock-data faker`) | +| `transformers` | `.transformers.ts`: `transform` functions that parse wire dates to `Date`. | none | +| `cli` | `.cli.ts`: a [command-line interface](#generated-cli) for the client, ready to use as a bin. It has typed flags, `--json` bodies, env auth, and `--page-all`. | none | +| `cli-docs` | `.cli.md`: a Markdown [reference for the generated CLI](#cli-reference-docs). It lists every command, flag, exit code, and credential variable. | none | ```sh redocly generate-client openapi.yaml --output src/client.ts --generator sdk --generator zod --generator mock ``` -`tanstack-query`, `swr`, and `cli` wrap the throw-mode `sdk` client, so they require `--error-mode throw`; `transformers` requires `--date-type Date`. +`tanstack-query`, `swr`, and `cli` wrap the throw-mode `sdk` client. +Because of this, they require `--error-mode throw`. +The `transformers` generator requires `--date-type Date`. See the [`zod`](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/zod), [`tanstack-query`](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/tanstack-query), and [`mock`](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/mock) examples. ### Generated CLI -The `cli` generator emits `.cli.ts` — a zero-dependency, bin-ready command-line interface over the generated client. -Path params are positional, query params become typed `--kebab-name` flags (enums list their choices in `--help`, array params repeat the flag), and JSON request bodies arrive via `--json ''`, `--json @file.json`, or `--json @-` (stdin). -Requests are validated before they are sent — selecting `cli` pulls in the generators it needs (`sdk` and `zod`), so nothing extra has to be listed. -That means the CLI's validation uses [zod](https://zod.dev/) at runtime: install it alongside the generated CLI (`npm i zod`). +The `cli` generator emits `.cli.ts`. +This file is a zero-dependency command-line interface for the generated client, ready to use as a bin. +Path parameters are positional. +Query parameters become typed `--kebab-name` flags. +Enum flags list their choices in `--help`, and array parameters repeat the flag. +Supply a JSON request body with `--json ''`, `--json @file.json`, or `--json @-` (stdin). +The CLI validates each request before it sends it. +When you select `cli`, the command also selects the generators it needs (`sdk` and `zod`), so you do not have to list them. +Because of this, the CLI validation uses [zod](https://zod.dev/) at runtime. +Install zod next to the generated CLI (`npm i zod`). ```sh redocly generate-client openapi.yaml --output src/client.ts --generator sdk --generator cli @@ -43,20 +54,37 @@ npx tsx src/client.cli.ts orders listOrders --page-all # one JSON page per lin npx tsx src/client.cli.ts schema createOrder # the operation's full contract ``` -`--help` lists the commands, and for tagged APIs those are grouped: run ` --help` for one command's flags. -An operationId also works on its own (` listOrders`) when it is unambiguous, so you don't have to know its group. - -Group and command names are cased differently, deliberately. -A group comes from an OpenAPI tag, which is prose — `Coffee Orders` is not typable without quoting — so it is slugged to `coffee-orders`. -A command name is the operationId, which is already an identifier, so it is used verbatim: `listOrders`, not `list-orders`. -That keeps one name for the operation across everything you generate — the CLI command, the TypeScript function, the Python method — so `listOrders` is searchable in your API description, your SDK, and your shell history alike. -Every global flag appears under `Global flags:` in the top-level help — `--server-url`, `--format json|ndjson`, `--dry-run`, `--page-all`, `--output`, `--token`, `--json` — together with the environment variables the CLI reads. - -Credentials come from environment variables derived from the file stem (constant-cased): bearer → `_TOKEN` (or `--token`), basic → `_USERNAME`/`_PASSWORD`, apiKey → `_API_KEY_`. -The help lists only what the description declares — an API with no bearer scheme shows no `--token` — and passing `--token` to such an API is a usage error (exit 4) naming the schemes it does accept, rather than a credential dropped in silence. -`--server-url` overrides the baked server; `--dry-run` prints the prepared request (credentials redacted) without sending it; blob responses require `--output `; SSE operations stream events as one JSON object per line. - -Exit codes are a documented contract, and errors print one JSON object to stderr so stdout stays clean for piping: +`--help` lists the commands. +For tagged APIs, the commands are grouped. +Run ` --help` to show the flags of one command. +An operationId also works without its group (` listOrders`) when the operationId is unambiguous. +Because of this, you do not have to know its group. + +Group names and command names use different cases, and this is deliberate. +A group name comes from an OpenAPI tag, which is prose. +You cannot type `Coffee Orders` without quotes, so the CLI converts the tag to a slug: `coffee-orders`. +A command name is the operationId, which is already an identifier. +Because of this, the CLI uses it unchanged: `listOrders`, not `list-orders`. +As a result, the operation keeps one name in all generated output: the CLI command, the TypeScript function, and the Python method. +You can search for `listOrders` in your API description, in your SDK, and in your shell history. +The top-level help shows every global flag under `Global flags:`: `--server-url`, `--format json|ndjson`, `--dry-run`, `--page-all`, `--output`, `--token`, and `--json`. +The same section shows the environment variables that the CLI reads. + +The CLI reads credentials from environment variables, with a prefix derived from the file stem in constant case. +For bearer auth, use `_TOKEN` (or `--token`). +For basic auth, use `_USERNAME` and `_PASSWORD`. +For apiKey auth, use `_API_KEY_`. +The help lists only the schemes that the description declares. +An API with no bearer scheme shows no `--token` flag. +If you pass `--token` to such an API, the CLI reports a usage error (exit 4) and names the schemes that the API accepts. +The CLI does not drop the credential silently. +`--server-url` overrides the built-in server URL. +`--dry-run` prints the prepared request with the credentials redacted and does not send it. +Blob responses require `--output `. +SSE operations stream events as one JSON object per line. + +The exit codes are a documented contract. +Errors print one JSON object to stderr, so stdout stays clean for pipes: | Code | Meaning | | ---- | --------------------------------------------------- | @@ -66,16 +94,23 @@ Exit codes are a documented contract, and errors print one JSON object to stderr | 3 | validation error (zod co-selected) | | 4 | usage error (unknown command or flag, bad `--json`) | -`schema ` prints one operation's complete contract as JSON — method and path, the path and query parameters with their types and descriptions, whether a JSON body is accepted, the request and response schemas, and the flags that change how a call behaves (`paginated`, `sse`, `blob`). -It is the CLI's machine-readable surface: a script, a test harness, or an agent can discover the tool with `--help`, then read one `schema` call per command instead of parsing help text written for humans. +`schema ` prints the complete contract of one operation as JSON. +The output includes the method and path, and the path and query parameters with their types and descriptions. +It also shows if the operation accepts a JSON body, the request and response schemas, and the flags that change call behavior (`paginated`, `sse`, `blob`). +This is the machine-readable surface of the CLI. +A script, a test harness, or an agent can discover the tool with `--help`. +It can then read one `schema` call for each command, and it does not have to parse help text written for humans. #### Compose and extend the CLI -The generated module is a library as well as a binary: it exports `COMMANDS`, `wiring`, and `run`, and self-executes only when it is the process entry. -That makes two things possible without touching generated files. +The generated module is a library and also a binary. +It exports `COMMANDS`, `wiring`, and `run`, and it executes itself only when it is the process entry. +This makes two things possible without changes to the generated files. -**One binary over several APIs.** -Set a top-level `client.cliOutput` and `redocly generate-client` (no api argument) emits a composed entry over every api that emits a cli module — each behind its alias from `apis:` as the namespace, reading credentials under `__*`: +**One binary for several APIs.** +Set a top-level `client.cliOutput`. +Then `redocly generate-client` (no api argument) emits a composed entry for every api that emits a cli module. +Each api uses its alias from `apis:` as its namespace, and it reads credentials under `__*`: ```yaml client: @@ -92,11 +127,14 @@ cafe shop listOrders --limit 3 # CAFE_SHOP_TOKEN cafe kitchen createOrder --json @o.json # CAFE_KITCHEN_TOKEN ``` -Colliding operationIds across descriptions are simply different commands, and each api keeps its own server URL, schemes, and credentials. +If two descriptions have the same operationId, the result is two different commands. +Each api keeps its own server URL, schemes, and credentials. **Commands the description doesn't have.** -A custom command is the same data shape plus a `handler`, so it inherits help, parsing, `schema`, and the exit codes. -This is how behavior that isn't in any description — a `login`, a doctor command — joins the binary, in a file you own: +A custom command is the same data shape plus a `handler`. +Because of this, it inherits the help, the parsing, `schema`, and the exit codes. +Use this to add behavior that is not in a description, for example a `login` or a doctor command. +The custom command lives in a file that you own: ```ts import { runCli, type CustomCommand } from '@redocly/client-generator'; @@ -116,41 +154,63 @@ const login: CustomCommand = { process.exit(await runCli([{ commands: [login] }, ...SOURCES], process.argv.slice(2))); ``` -Credentials resolve from `wiring.env`, so a wrapper that reads a credentials file merges it there (`env: { ...process.env, ...stored }`) and a stored token is indistinguishable from one set in the shell. -The generator itself ships no credential store and no login — every API's flow differs, so those stay yours, and this section is the recipe. +The CLI resolves credentials from `wiring.env`. +A wrapper that reads a credentials file merges the file into that env (`env: { ...process.env, ...stored }`). +Then a stored token behaves the same as a token set in the shell. +The generator itself supplies no credential store and no login command. +The auth flow of each API is different, so you supply these parts. +This section shows the procedure. -The CLI uses top-level `await`, so the nearest `package.json` must set `"type": "module"` — otherwise `tsx` reports `Top-level await is currently not supported with the "cjs" output format`, which doesn't point at the fix. -To ship it as a real bin, compile with `tsc` and point `package.json`'s `bin` at the compiled file. +The CLI uses top-level `await`, so the nearest `package.json` must set `"type": "module"`. +Without this setting, `tsx` reports `Top-level await is currently not supported with the "cjs" output format`, and that message does not point to the fix. +To ship the CLI as a real bin, compile it with `tsc`. +Then point the `bin` field of `package.json` at the compiled file. #### CLI reference docs -The `cli-docs` generator writes `.cli.md`: a Markdown reference with the usage line, the global flags, the credential environment variables, the exit-code table, and one section per command listing its positionals and flags with types, defaults, and descriptions. -It renders from the same command table the CLI dispatches on, so the page cannot drift from the tool it documents — regenerate and the docs follow. -Selecting it pulls in the CLI it describes, so `--generator cli-docs` is enough. +The `cli-docs` generator writes `.cli.md`, a Markdown reference. +The page contains the usage line, the global flags, the credential environment variables, and the exit-code table. +It also contains one section for each command. +Each section lists the positionals and flags of the command with their types, defaults, and descriptions. +The page renders from the same command table that the CLI dispatches on. +Because of this, the page always matches the tool that it documents. +When you regenerate, the docs update with the tool. +When you select `cli-docs`, the command also selects the CLI that the page describes, so `--generator cli-docs` is enough. ```sh redocly generate-client openapi.yaml --output src/client.ts --generator cli-docs ``` -Two options shape the page, under `client.options.cli-docs`: +Two options control the page, under `client.options.cli-docs`: -| Option | Type | Description | -| ------------- | ------- | --------------------------------------------------------------------------------------------------- | -| `title` | string | Page heading. Defaults to ` CLI`. | -| `frontmatter` | boolean | Emit YAML front matter (`title`) above the heading, for docs sites that expect it. Default `false`. | +| Option | Type | Description | +| ------------- | ------- | ---------------------------------------------------------------------------------------------------------- | +| `title` | string | The page heading. The default is ` CLI`. | +| `frontmatter` | boolean | Emit YAML front matter (`title`) above the heading, for docs sites that expect it. The default is `false`. | -For a different structure or wording, [eject the generator](../commands/eject-generator.md) — the renderer is the template, so `redocly eject-generator cli-docs` hands you the page layout as code you own, with no template syntax to learn. +For a different structure or wording, [eject the generator](../commands/eject-generator.md). +The renderer is the template. +Because of this, `redocly eject-generator cli-docs` gives you the page layout as code that you own, with no template syntax to learn. The same reference for the language SDKs is next. ### Language SDKs -`python`, `go`, and `php` emit a full SDK for that language — one self-contained file, no dependencies beyond the language's own HTTP support (`httpx` for Python; the standard library for Go; the curl extension for PHP). - -One file is the deliverable, not a limitation we haven't gotten to: it can be downloaded from a docs page, committed, and read end to end, and it has no package to publish or import graph to wire up. -A description the size of a large public API produces a file of a few megabytes, which every one of these languages loads without trouble. -If you want a different layout, [eject the generator](../commands/eject-generator.md) — `run` returns the list of files, so splitting the output is a change to your own copy. - -**They are the TypeScript client in another language.** Every capability is the same: typed models with `allOf` flattened, enums, discriminated unions decoded by their discriminator, one method per operation, auth, retries with `Retry-After` and jittered backoff, timeouts, idempotency keys, middleware, pagination iterators, SSE streaming, multipart bodies, binary downloads, typed response-header envelopes, and server-URL helpers for templated servers. +The `python`, `go`, and `php` generators each emit a full SDK for that language. +The SDK is one self-contained file. +It has no dependencies other than the HTTP support of the language: `httpx` for Python, the standard library for Go, and the curl extension for PHP. + +One file is the intended deliverable, not a limitation. +Users can download the file from a docs page, commit it, and read it from start to end. +There is no package to publish and no import graph to connect. +A description the size of a large public API produces a file of a few megabytes. +Each of these languages loads a file of that size without problems. +If you want a different layout, [eject the generator](../commands/eject-generator.md). +The `run` function returns the list of files, so you can split the output with a change to your own copy. + +**They are the TypeScript client in another language.** +Every capability is the same: typed models with `allOf` flattened, enums, discriminated unions decoded by their discriminator, and one method per operation. +The SDKs also include auth, retries with `Retry-After` and jittered backoff, timeouts, idempotency keys, middleware, and pagination iterators. +They also include SSE streaming, multipart bodies, binary downloads, typed response-header envelopes, and server-URL helpers for templated servers. Configuration is the same too: [`serverUrl`](../commands/generate-client.md), [`dateType`](../commands/generate-client.md), [`pagination`](../configuration/reference/client.md#pagination-object), and [`codeSamples`](../configuration/reference/client.md) all apply. ```python @@ -185,25 +245,27 @@ for order, err := range api.ListOrdersItems(ctx, nil) { #### Where the languages genuinely differ -Only where the language leaves no choice: +The SDKs differ only where the language gives no choice: -| Topic | TypeScript | Python | PHP | Go | -| -------------------- | ---------------------------------- | ------------------------------------------- | --------------------------------------- | -------------------------------------------- | -| Error handling | `throw` or `result` (`errorMode`) | `throw` or `result` (`errorMode`) | exceptions — the language's error idiom | `(T, error)` — the language's error idiom | -| Dates (`Date` mode) | `Date` | `datetime` / `date` | `\DateTimeImmutable` | `time.Time` / `Date` | -| Response headers | `{ envelope: true }` per call | `_with_headers()` | `WithHeaders()` | `WithHeaders` | -| Auth credentials | string or provider function | string or callable | string or callable | provider function only (no union types) | -| Reserved-word fields | not applicable | trailing `_` (`type_`), wire name preserved | trailing `_`, wire name preserved | trailing `_` (`Type_`), `json` tag preserved | -| File layout | `single` or `split` (`outputMode`) | one file | one file | one file | -| Namespacing | ES module — the file path | module name from the output stem | namespace from the API title | `package client`, or `goPackage` | -| Runtime location | embedded or package (`runtime`) | embedded | embedded | embedded | +| Topic | TypeScript | Python | PHP | Go | +| -------------------- | ---------------------------------- | -------------------------------------- | -------------------------------------------- | ---------------------------------------------- | +| Error handling | `throw` or `result` (`errorMode`) | `throw` or `result` (`errorMode`) | exceptions (the error idiom of the language) | `(T, error)` (the error idiom of the language) | +| Dates (`Date` mode) | `Date` | `datetime` / `date` | `\DateTimeImmutable` | `time.Time` / `Date` | +| Response headers | `{ envelope: true }` per call | `_with_headers()` | `WithHeaders()` | `WithHeaders` | +| Auth credentials | string or provider function | string or callable | string or callable | provider function only (no union types) | +| Reserved-word fields | not applicable | trailing `_` (`type_`), wire name kept | trailing `_`, wire name kept | trailing `_` (`Type_`), `json` tag kept | +| File layout | `single` or `split` (`outputMode`) | one file | one file | one file | +| Namespacing | ES module (the file path) | module name from the output stem | namespace from the API title | `package client`, or `goPackage` | +| Runtime location | embedded or package (`runtime`) | embedded | embedded | embedded | -`argsStyle` shapes TypeScript call sites; each language SDK follows its own idiom instead (keyword arguments, named arguments, a params struct). -Setting an option a language can't apply prints a warning naming the option and the reason, so it never disappears silently. +`argsStyle` applies only to TypeScript call sites. +Each language SDK follows its own idiom: keyword arguments, named arguments, or a params struct. +If you set an option that a language cannot apply, the generator prints a warning with the option name and the reason. +The option never disappears silently. #### Auth, middleware, and reserved names by language -Auth accepts a static credential or a provider resolved per request: +Auth accepts a static credential, or a provider function that the client resolves for each request: ```python client = Client(auth={"bearer": "TOKEN"}) @@ -225,7 +287,9 @@ api := client.New(client.Config{Auth: client.Auth{ }}) ``` -Middleware is the language's natural shape, and is **not** PSR-15/PSR-18 or an HTTPX event hook — it is this contract: +Middleware follows the natural shape of each language. +It is **not** PSR-15/PSR-18 or an HTTPX event hook. +It is this contract: ```php // PHP: an onion. Each callable receives the request array and the next link. @@ -260,18 +324,26 @@ api := client.New(client.Config{Middleware: []client.Middleware{{ }}}) ``` -A property or parameter whose name is a reserved word gets a trailing underscore, while the wire name is preserved — `tag.type_` in Python, `$tag->type_` in PHP, `tag.Type_` in Go, all serializing as `type`. +A property or parameter whose name is a reserved word gets a trailing underscore. +The wire name does not change. +For example, `tag.type_` in Python, `$tag->type_` in PHP, and `tag.Type_` in Go all serialize as `type`. The same applies to method arguments: `list_tags(type_=...)`, `ListTagsParams{Type_: ...}`. -Type and method **names** are resolved once, in the shared model, against a reserved set that is the union across the supported languages. -A schema therefore keeps the same name in every SDK you generate from the description — `Error` becomes `Error_2` in the Python SDK too, even though Python would accept `Error`, so an API's TypeScript, Python, PHP, and Go clients stay talkable-about with one vocabulary. -Every rename is reported with its cause, so a publisher who wants a different name renames the schema or operation in the description. +The generator resolves type and method **names** once, in the shared model. +It checks them against a reserved set that is the union across the supported languages. +Because of this, a schema keeps the same name in every SDK that you generate from the description. +For example, `Error` becomes `Error_2` in the Python SDK too, although Python accepts `Error`. +As a result, the TypeScript, Python, PHP, and Go clients of an API share one vocabulary. +The generator reports each rename with its cause. +A publisher who wants a different name can rename the schema or the operation in the description. ## Package runtime -By default the runtime is embedded in the generated file, so the client is self-contained. -With [`--runtime package`](../commands/generate-client.md#choose-a-runtime) the generated file instead imports the runtime from `@redocly/client-generator` — your application code is **identical in both modes** (same exports, same call shapes); only where the engine lives changes. -Choose `package` when you want engine fixes and improvements via `npm update @redocly/client-generator`, with no regeneration. +By default, the generator embeds the runtime in the generated file, so the client is self-contained. +With [`--runtime package`](../commands/generate-client.md#choose-a-runtime), the generated file imports the runtime from `@redocly/client-generator` instead. +Your application code is **identical in both modes**: the same exports and the same call shapes. +Only the location of the engine changes. +Select `package` to get engine fixes and improvements through `npm update @redocly/client-generator`, with no regeneration. Install the runtime as a regular dependency and set the mode in `redocly.yaml`: @@ -284,14 +356,18 @@ client: runtime: package # default: inline (self-contained) ``` -An incompatible generated-file/runtime pair fails your `tsc` build (the descriptor `satisfies` check) rather than misbehaving at runtime. +If the generated file and the runtime are incompatible, your `tsc` build fails on the descriptor `satisfies` check. +The pair does not misbehave at runtime. Package mode works with both output modes and every generator. See the [`package-runtime` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/package-runtime). ## Run with Node directly -Node 22.7+ runs TypeScript natively (type stripping), so you can execute a script that uses the generated client with plain `node` — no `tsx`, no build step. -Node resolves import specifiers literally — there is no `.js` → `.ts` remap — so generate with [`--import-ext ts`](../commands/generate-client.md#options) to get real on-disk `.ts` specifiers, and import the client with a `.ts` extension in your own code: +Node 22.7+ runs TypeScript natively with type stripping. +Because of this, you can run a script that uses the generated client with plain `node`, without `tsx` and without a build step. +Node resolves import specifiers literally, with no `.js` to `.ts` remap. +Because of this, generate with [`--import-ext ts`](../commands/generate-client.md#options) to get real on-disk `.ts` specifiers. +Import the client with a `.ts` extension in your own code: ```bash redocly generate-client openapi.yaml -o src/api/client.ts --import-ext ts @@ -308,17 +384,23 @@ const menu = await listMenuItems({ limit: 3 }); node src/main.ts ``` -Keep the default `js` when the client goes through `tsc` or a bundler — plain `tsc` rejects `.ts` specifiers unless the project enables `allowImportingTsExtensions`. +Keep the default `js` when the client goes through `tsc` or a bundler. +Plain `tsc` rejects `.ts` specifiers unless the project enables `allowImportingTsExtensions`. Loaders such as `tsx` remap `.js` to `.ts` themselves, so they work with the default. See the [`node-native` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/node-native). -**Every generated TypeScript file is erasable TypeScript**, so type stripping alone is enough — the client, the zod module, and the generated CLI all run under plain `node` with no build step. -Nothing emitted needs a transform to become JavaScript: no `enum`, no `namespace`, and no constructor parameter properties (`constructor(readonly id: string)`), which strip-only mode rejects because it would have to generate assignments. +**Every generated TypeScript file is erasable TypeScript**, so type stripping alone is enough. +The client, the zod module, and the generated CLI all run under plain `node` with no build step. +No emitted code needs a transform to become JavaScript. +The output contains no `enum`, no `namespace`, and no constructor parameter properties (`constructor(readonly id: string)`). +Strip-only mode rejects these constructs, because it would have to generate assignments. ## Authentication -Credentials are **per instance**: they live in the client's config (`ClientConfig.auth`), and each operation automatically sends the credentials its `security` requires. -A setter is generated for each `securityScheme` the runtime can apply: +Credentials are **per instance**. +They live in the client config (`ClientConfig.auth`). +Each operation automatically sends the credentials that its `security` requires. +The generator emits a setter for each `securityScheme` that the runtime can apply: | Scheme | Setter | Applied as | | ------------------------------ | ----------------------------------------- | ---------------------------------------- | @@ -326,10 +408,14 @@ A setter is generated for each `securityScheme` the runtime can apply: | HTTP `basic` | `setBasicAuth(user, pass)` | `Authorization: Basic ` | | `apiKey` (header/query/cookie) | `setApiKey(key)` / `setApiKey(key)` | the named header, query param, or cookie | -`setApiKey` is unsuffixed for a single apiKey scheme; otherwise each gets `setApiKey`. -`mutualTLS` is not injectable. -Cookie apiKey credentials travel in the `Cookie` request header, which browsers refuse to set — cookie auth works only in server-side clients (the generator warns when a spec declares one). -Bearer and apiKey credentials accept a **`TokenProvider`** — a string or a (possibly async) function called per request, useful for refresh flows: +For a single apiKey scheme, the setter is `setApiKey` without a suffix. +For more than one scheme, each setter is `setApiKey`. +The runtime cannot inject `mutualTLS`. +Cookie apiKey credentials travel in the `Cookie` request header, and browsers refuse to set this header. +Because of this, cookie auth works only in server-side clients. +The generator warns when a spec declares a cookie scheme. +Bearer and apiKey credentials accept a **`TokenProvider`**: a string, or a function (possibly async) that the client calls for each request. +This is useful for refresh flows: ```ts import { setBearer } from './client.ts'; @@ -337,10 +423,13 @@ import { setBearer } from './client.ts'; setBearer(async () => await getFreshAccessToken()); ``` -Each setter is shorthand for the exported `client` instance's `auth` member (`export const setBearer = client.auth.bearer;`), so it configures that instance. -Equivalently, pass credentials up front with `configure({ auth: { … } })` or set them via `client.auth.bearer(…)` / `client.auth.basic(…)` / `client.auth.apiKey(scheme, …)`. +Each setter is shorthand for the `auth` member of the exported `client` instance (`export const setBearer = client.auth.bearer;`). +Because of this, the setter configures that instance. +As an alternative, pass credentials up front with `configure({ auth: { … } })`. +Or set them with `client.auth.bearer(…)`, `client.auth.basic(…)`, or `client.auth.apiKey(scheme, …)`. -For **multiple independent instances** with different credentials, build extra clients over the same generated descriptors — the generated module exports `createClient`, the `OPERATIONS` descriptors, and the `Ops` type in both runtimes: +For **multiple independent instances** with different credentials, build extra clients from the same generated descriptors. +The generated module exports `createClient`, the `OPERATIONS` descriptors, and the `Ops` type in both runtimes: ```ts import { createClient } from '@redocly/client-generator'; @@ -355,9 +444,13 @@ const publicApi = createClient(OPERATIONS, { serverUrl: 'https://api.exampl ## Argument style -By default (`--args-style flat`) each operation takes positional arguments — path params in URL order, then `params` (query), `body`, `headers`, and `cookies` — with the per-call `init` last. -Cookie parameters are serialized into the `Cookie` request header, which browsers refuse to set — like cookie apiKey auth, they work only in server-side clients. -With `--args-style grouped`, every input is bundled into one `vars` object typed as the operation's `Variables`: +By default (`--args-style flat`), each operation takes positional arguments. +The order is: path parameters in URL order, then `params` (query), `body`, `headers`, and `cookies`. +The per-call `init` comes last. +The client serializes cookie parameters into the `Cookie` request header, and browsers refuse to set this header. +Because of this, cookie parameters, like cookie apiKey auth, work only in server-side clients. +With `--args-style grouped`, one `vars` object holds every input. +Its type is the operation's `Variables`: ```ts // flat (default) @@ -367,28 +460,40 @@ await updateOrder('ord_01khr…', { ...orderBody }); await updateOrder({ orderId: 'ord_01khr…', body: { ...orderBody } }); ``` -An unknown top-level key in the grouped object (for example a leftover flat-style `{ limit: 10 }` instead of `{ params: { limit: 10 } }`) fails the call with a `TypeError` naming the key. -TypeScript catches this at compile time; the runtime check covers transpilers that skip type-checking, so a mis-shaped call never silently drops data. +An unknown top-level key in the grouped object fails the call with a `TypeError` that names the key. +An example is a leftover flat-style `{ limit: 10 }` instead of `{ params: { limit: 10 } }`. +TypeScript catches this at compile time. +The runtime check covers transpilers that skip type checks. +Because of this, a call with the wrong shape never drops data silently. ## Read-only properties -A property marked `readOnly: true` is server-managed, so the generated request body type leaves it out: a body that references a named schema becomes `Omit`, and an inline object simply drops those properties. +The server manages a property marked `readOnly: true`. +Because of this, the generated request body type leaves the property out. +A body that references a named schema becomes `Omit`. +An inline object drops those properties. Response types keep them. The zod schemas and the mock factories read the same flag, so the type, the runtime validation, and the fixtures agree. -Where `readOnly` sits matters, and it follows the specification version: +The position of `readOnly` matters, and it follows the specification version: - **OpenAPI 3.1** uses JSON Schema 2020-12, where `$ref` is an ordinary keyword. - Keywords beside a `$ref` take effect, so `{ $ref: './Entitlements.yaml', readOnly: true }` marks the property read-only. -- **OpenAPI 3.0 and 2.0** predate that: a `$ref` replaces the whole schema object, so a sibling `readOnly` has no meaning and is ignored. - Generation warns when it finds one, naming the property, because the intent is usually clear and silence would leave the property in every request body. + Keywords next to a `$ref` take effect. + Because of this, `{ $ref: './Entitlements.yaml', readOnly: true }` marks the property read-only. +- **OpenAPI 3.0 and 2.0** are older than that model. + A `$ref` replaces the whole schema object, so a sibling `readOnly` has no meaning, and the generator ignores it. + Generation warns when it finds a sibling `readOnly` and names the property. + The intent is usually clear, and silence would keep the property in every request body. The [`spec-ref-siblings`](../rules/oas/spec-ref-siblings.md) rule flags the same thing when you lint. To mark a referenced property read-only in 3.0, inline the schema or wrap the `$ref` in an `allOf`. ## Error handling -By default (`--error-mode throw`) an operation throws `ApiError` on any non-2xx response and returns the success body directly. -With `--error-mode result` it never throws for HTTP errors, returning a discriminated `Result` whose `error` is typed from the description's 4xx/5xx bodies: +By default (`--error-mode throw`), an operation throws `ApiError` on a non-2xx response. +It returns the success body directly. +With `--error-mode result`, the operation never throws for HTTP errors. +It returns a discriminated `Result`. +The `error` type comes from the 4xx/5xx bodies in the description: ```ts // throw (default) @@ -409,8 +514,10 @@ The choice is fixed at generate time. ## Middleware -Beyond the single `onRequest`/`onResponse`/`onError` hooks on `ClientConfig`, the client takes **composable middleware** for cross-cutting concerns (auth refresh, logging, tracing, request IDs). -Register with `use()` (shorthand for `client.use()`); it accepts several at once: +The client has single `onRequest`/`onResponse`/`onError` hooks on `ClientConfig`. +It also takes **composable middleware** for concerns that apply to many calls: auth refresh, logs, traces, and request IDs. +Register middleware with `use()`, a shorthand for `client.use()`. +It accepts several middleware at once: ```ts import { use } from './client.ts'; @@ -425,29 +532,40 @@ use({ }); ``` -`onRequest` runs in registration order; `onResponse` runs in reverse order. -`onRequest` may mutate `ctx` (`url`, `method`, `headers`, and `body` — body edits are serialized and sent); `onResponse` may return a replacement `Response`. -`onError` (throw mode only) is threaded through each middleware. -`ctx.operation`'s fields are typed as literal unions from the description (`OperationId`/`OperationPath`/`OperationTag`), so `ctx.operation.id === '…'` and `ctx.operation.tags.includes('…')` autocomplete, and a misspelled operation id fails compilation instead of silently never matching. -A header for a single call instead goes in that operation's trailing `init` argument. -Per-request headers merge lowest to highest — the caller always wins: +`onRequest` hooks run in registration order. +`onResponse` hooks run in reverse order. +`onRequest` can change `ctx`: `url`, `method`, `headers`, and `body`. +The client serializes and sends body edits. +`onResponse` can return a replacement `Response`. +The client threads `onError` (throw mode only) through each middleware. +The fields of `ctx.operation` are typed as literal unions from the description (`OperationId`/`OperationPath`/`OperationTag`). +Because of this, `ctx.operation.id === '…'` and `ctx.operation.tags.includes('…')` autocomplete. +An operation id with a spelling error fails compilation, and it does not silently miss all matches. +To set a header for a single call, use the trailing `init` argument of that operation. +Per-request headers merge from the lowest to the highest priority, and the caller always wins: 1. Injected auth credentials. 2. Typed header parameters. 3. The caller's `init.headers`. -Outside browsers, the client also identifies itself to the API with an `X-Redocly-Client` header (useful for the API owner's telemetry). -Override it with `configure({ clientHeader: 'my-service/2.0' })`, or disable it with `clientHeader: false`. -Browsers never send it — a custom header would force a CORS preflight. +Outside browsers, the client also identifies itself to the API with an `X-Redocly-Client` header. +The API owner can use this header for telemetry. +Override the header with `configure({ clientHeader: 'my-service/2.0' })`. +Disable it with `clientHeader: false`. +Browsers never send it, because a custom header would force a CORS preflight. -`use()` appends to the middleware chain, composing with any already-registered or publisher pre-configured middleware. -`configure({ middleware: [...] })` replaces the whole chain — use it to reset, but prefer `use()` to add to existing (including [publisher pre-configured](./customize-client-generation.md#publisher-defaults)) middleware. +`use()` appends to the middleware chain. +It composes with middleware that is already registered or that the publisher pre-configured. +`configure({ middleware: [...] })` replaces the whole chain. +Use it to reset the chain. +But prefer `use()` to add to existing middleware, including [publisher pre-configured](./customize-client-generation.md#publisher-defaults) middleware. See the [`configure-and-middleware` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/configure-and-middleware) for a runnable version. ## Retries -Retry is **opt-in**, configured through `ClientConfig` with an optional per-call override: +Retry is **opt-in**. +Configure it through `ClientConfig`, with an optional per-call override: ```ts configure({ retry: { retries: 3 } }); // the module's client instance @@ -455,24 +573,41 @@ const other = createClient(OPERATIONS, { retry: { retries: 3 } }); // anoth await getOrderById('ord_123', {}, { retry: { retries: 5 } }); // per call ``` -By default only **idempotent** methods (`GET`, `HEAD`, `PUT`, `DELETE`, `OPTIONS`) are retried, on a network error or a transient status (`408`, `429`, `500`, `502`, `503`, `504`). -`POST`/`PATCH` are not, since re-sending can duplicate side effects — opt in with a custom `retryOn` when safe. +By default, the client retries only **idempotent** methods (`GET`, `HEAD`, `PUT`, `DELETE`, `OPTIONS`). +It retries them on a network error or a transient status (`408`, `429`, `500`, `502`, `503`, `504`). +The client does not retry `POST`/`PATCH`, because a repeated send can duplicate side effects. +Opt in with a custom `retryOn` when a retry is safe. -A custom `retryOn` **replaces** the default policy entirely — a predicate like `({ response }) => (response?.status ?? 0) >= 500` silently stops retrying network errors and timeouts, which have no `response`. +A custom `retryOn` **replaces** the whole default policy. +A predicate like `({ response }) => (response?.status ?? 0) >= 500` silently stops retries for network errors and timeouts, because these have no `response`. Compose with the exported default instead: `retryOn: (ctx) => defaultRetryOn(ctx) || myRule(ctx)`. -For APIs that support [idempotency keys](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/), set `idempotencyKey: true` (or a key factory) on the instance: every `POST`/`PATCH` gets an `Idempotency-Key` header — one stable key per logical call, re-sent unchanged on every retry attempt — and the default retry policy then treats those requests as safe to retry. -Per call, pass a literal key (`{ idempotencyKey: 'order-42-submit' }`) or `false` to skip; a caller-set `Idempotency-Key` header always wins. -Backoff is exponential with full jitter (`retryStrategy: 'fixed'` for a constant delay); a `Retry-After` header takes precedence; an aborted `AbortSignal` stops retries immediately. - -A `timeout` (milliseconds) aborts an attempt that takes too long — including reading the body — and composes with your own `AbortSignal`. -Each retry attempt gets a fresh budget; a timed-out attempt retries under the same policy as a network error. -When retries are exhausted, the failure surfaces as a `TimeoutError` (exported next to `ApiError`) carrying `operationId`, the effective `timeout`, and the `attempt` number — everything a log line needs. -Set it on the instance (`configure({ timeout: 10_000 })`) or per call (`{ timeout: 500 }`, where `0` disables the instance default). -SSE streams are long-lived by design and never inherit the instance timeout. - -A retry **resends the same request** — the `onRequest` chain, `config.headers()`, and body serialization run once and are reused across attempts. -To refresh a token, signature, or timestamp per attempt, do it in `onResponse`/`onError` or a custom `retryOn` rather than expecting `onRequest` to re-run. +For APIs that support [idempotency keys](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/), set `idempotencyKey: true` (or a key factory) on the instance. +Then every `POST`/`PATCH` gets an `Idempotency-Key` header. +The key is one stable value per logical call, and each retry attempt sends the same value. +The default retry policy then treats those requests as safe to retry. +Per call, pass a literal key (`{ idempotencyKey: 'order-42-submit' }`), or pass `false` to skip the header. +An `Idempotency-Key` header set by the caller always wins. +Backoff is exponential with full jitter. +Set `retryStrategy: 'fixed'` for a constant delay. +A `Retry-After` header takes precedence. +An aborted `AbortSignal` stops retries immediately. + +A `timeout` (milliseconds) aborts an attempt that takes too long, including the body read. +The timeout composes with your own `AbortSignal`. +Each retry attempt gets a fresh time budget. +An attempt that times out retries under the same policy as a network error. +When no retries remain, the failure surfaces as a `TimeoutError`, exported next to `ApiError`. +The error carries the `operationId`, the effective `timeout`, and the `attempt` number. +This is everything a log line needs. +Set the timeout on the instance (`configure({ timeout: 10_000 })`) or per call (`{ timeout: 500 }`). +A per-call value of `0` disables the instance default. +SSE streams stay open by design and never inherit the instance timeout. + +A retry **resends the same request**. +The `onRequest` chain, `config.headers()`, and body serialization run once, and all attempts reuse the result. +To refresh a token, a signature, or a timestamp for each attempt, do it in `onResponse`/`onError` or in a custom `retryOn`. +Do not expect `onRequest` to run again. | `RetryConfig` field | Type | Default | | ------------------- | ---------------------------------------------------- | -------------------------------------------------- | @@ -482,8 +617,9 @@ To refresh a token, signature, or timestamp per attempt, do it in `onResponse`/` | `jitter` | `boolean` | `true` | | `retryOn` | `(ctx: RetryContext) => boolean \| Promise` | idempotent-only predicate | -A custom `retryOn` receives the failed attempt's `RetryContext` (`attempt`, `request`, and exactly one of `response` / `error`) and **fully replaces** the default. -To inspect a response body, read `ctx.response.clone()` — the body is a single-use stream: +A custom `retryOn` receives the `RetryContext` of the failed attempt: `attempt`, `request`, and exactly one of `response` / `error`. +It **fully replaces** the default. +To examine a response body, read `ctx.response.clone()`, because the body is a single-use stream: ```ts await createOrder(body, { @@ -509,13 +645,16 @@ The default (`form`, `explode: true`) repeats array values: | `spaceDelimited` | `false` | `key=a%20b` | | `pipeDelimited` | `false` | `key=a\|b` | -Delimiters are literal (values are still percent-encoded). -`allowReserved: true` leaves the RFC-3986 reserved set un-encoded. -Object-valued params serialize as `deepObject` brackets (`key[sub]=val`). +Delimiters are literal. +The client still percent-encodes the values. +`allowReserved: true` keeps the RFC-3986 reserved set un-encoded. +Parameters with object values serialize as `deepObject` brackets (`key[sub]=val`). ## Multipart uploads -A `multipart/form-data` body whose schema is an **object** is generated as a typed object; pass a plain object and the client serializes it to `FormData` (after the `onRequest` chain, so middleware can mutate it). +A `multipart/form-data` body whose schema is an **object** generates as a typed object. +Pass a plain object, and the client serializes it to `FormData`. +The serialization happens after the `onRequest` chain, so middleware can change the object. Binary fields (`format: binary`) are typed as `Blob`: ```ts @@ -523,13 +662,15 @@ Binary fields (`format: binary`) are typed as `Blob`: await upload({ file, orgId: 'org_1', tags: ['a', 'b'] }); ``` -`Blob`/strings pass through, arrays append one field per item, nested objects are JSON-encoded, `undefined`/`null` are skipped. -A multipart body whose schema isn't a concrete object keeps the raw `FormData` type. +`Blob` values and strings pass through unchanged. +Arrays append one field per item. +The client JSON-encodes nested objects and skips `undefined`/`null`. +A multipart body whose schema is not a concrete object keeps the raw `FormData` type. `format: byte` (base64) stays a `string`. ## Response decoding -The client reads each response by negotiating from its `Content-Type` (JSON, then `text/*`, then `Blob`). +The client selects a reader for each response from its `Content-Type`: JSON, then `text/*`, then `Blob`. Force a reader per call with `parseAs`: ```ts @@ -540,13 +681,15 @@ const res = await getMenuItemPhoto('prd_123', { parseAs: 'stream' }); It changes the runtime reader only, not the static return type. An operation whose success response declares no content is typed `void`. -However, if the server sends a JSON body anyway (a gap in the API description), the runtime still parses and returns it rather than silently dropping real data. -Reach it with a cast while the description catches up. +But if the server sends a JSON body anyway (a gap in the API description), the runtime still parses and returns the body. +It does not drop real data silently. +Access the body with a cast until the description declares it. ## Response headers (envelope) -By default throw mode returns only the parsed success body. -When you need response headers (pagination totals, rate limits, `Location`, and so on) without switching to `--error-mode result`, pass `{ envelope: true }` on that call: +By default, throw mode returns only the parsed success body. +Sometimes you need response headers, for example pagination totals, rate limits, or `Location`. +To get them without a switch to `--error-mode result`, pass `{ envelope: true }` on that call: ```ts // Flat args (default): query/body slots, then per-call init. @@ -560,23 +703,31 @@ response.headers.get('X-Undocumented'); // anything not declared in OpenAPI const envelope = await client.listCustomers({ params: { limit: 1 } }, { envelope: true }); ``` -- `headers` is a safe camelCase object of headers declared on the operation's success response. - String, number, and boolean schemas drive the TypeScript type and number/boolean coercion. - Complex header schemas remain strings because HTTP exposes header values as text. - Required response headers are required properties — the type trusts the API description, the same way response body types do. - Colliding normalized names get a deterministic numeric suffix. -- `response` is the raw `Response` — use it for undocumented headers. +- `headers` is a safe camelCase object of the headers declared on the operation's success response. + String, number, and boolean schemas drive the TypeScript type and the number/boolean coercion. + Complex header schemas stay strings, because HTTP exposes header values as text. + Required response headers are required properties. + The type trusts the API description, the same as the response body types do. + Normalized names that collide get a deterministic numeric suffix. +- `response` is the raw `Response`. + Use it for undocumented headers. - Non-2xx responses still throw `ApiError`. -- Default call sites stay body-only (non-breaking), including calls that pass other options (`headers`, `signal`, `parseAs`, a retry override). -- In `--error-mode result` the flag is ignored; that mode already returns `response`. -- The TanStack Query and SWR wrappers don't accept `envelope`. - It's excluded from their options and stripped from the forwarded call, so cached data is always the plain body. +- Default call sites continue to return only the body, so the flag is non-breaking. + This includes calls that pass other options (`headers`, `signal`, `parseAs`, a retry override). +- In `--error-mode result`, the client ignores the flag. + That mode already returns `response`. +- The TanStack Query and SWR wrappers do not accept `envelope`. + Their options exclude it, and the wrappers strip it from the forwarded call. + Because of this, cached data is always the plain body. Call the sdk function directly when you need headers. -- The Python, PHP, and Go SDKs expose the same information as separate variants — `_with_headers()`, `WithHeaders()`, and `WithHeaders` — emitted only for operations that declare success-response headers (those languages cannot vary a return type on a flag). +- The Python, PHP, and Go SDKs expose the same information as separate variants: `_with_headers()`, `WithHeaders()`, and `WithHeaders`. + The generator emits these variants only for operations that declare success-response headers. + Those languages cannot change a return type with a flag. ## Runtime validation -The `zod` generator emits `operationSchemas` — request/response validators keyed by operationId — and the `zodValidation` middleware that wires them into the client: +The `zod` generator emits `operationSchemas`, a set of request and response validators keyed by operationId. +It also emits the `zodValidation` middleware that connects them to the client: ```ts import { use } from './api/client'; @@ -585,21 +736,36 @@ import { zodValidation } from './api/client.zod'; use(zodValidation()); // validate request bodies and JSON responses ``` -The two directions default differently, because they catch different parties' bugs: +The two directions have different defaults, because they catch bugs from different parties: -- An invalid **request** body throws `ZodValidationError` before any network call — it is the caller's own bug, caught at the cheapest possible moment. -- A successful JSON **response** that drifts from its schema **warns by default** (via `console.warn`, or a custom `onViolation` callback) and lets the call succeed — a server drifting from its description should not crash the consumer. Pass `response: 'throw'` for the strict behavior (it then throws even on result-mode clients), or `response: false` to skip. +- An invalid **request** body throws `ZodValidationError` before a network call. + This is the caller's own bug, caught at the least expensive moment. +- A successful JSON **response** that does not match its schema **warns by default** and lets the call succeed. + The warning goes to `console.warn` or to a custom `onViolation` callback. + A server that does not match its description must not crash the consumer. + Pass `response: 'throw'` for the strict behavior; it then throws even on result-mode clients. + Pass `response: false` to skip response validation. -`ZodValidationError` carries `operationId`, `direction`, the raw zod `issues`, and flattened `violations` — each with the full nested path (union branches included) and a truncated preview of the offending value, so the failing field is identifiable without reproducing the payload. -Note that previews can surface payload data; point `onViolation` at a scrubbed logger when responses may carry secrets. +`ZodValidationError` carries the `operationId`, the `direction`, the raw zod `issues`, and the flattened `violations`. +Each violation has the full nested path (union branches included) and a truncated preview of the bad value. +Because of this, you can identify the failing field without a reproduction of the payload. +Note that previews can show payload data. +Point `onViolation` at a scrubbed logger when responses can carry secrets. -For servers that reject undeclared properties, `stripRequestBodies: true` replaces the outgoing body with the parsed result, dropping any key the schema does not declare (a spread like `{ ...entity }` compiles past TypeScript's excess-property check but would otherwise reach the wire as-is). -Operations without a JSON body pass through untouched, and payloads are never mutated unless `stripRequestBodies` is set. -Pass `{ request: false }` to narrow the scope, or import a schema from `operationSchemas` for a one-off check. +Some servers reject properties that the schema does not declare. +For those servers, set `stripRequestBodies: true`. +It replaces the outgoing body with the parsed result and drops each key that the schema does not declare. +A spread like `{ ...entity }` compiles past TypeScript's excess-property check, but without this option it reaches the wire unchanged. +Operations without a JSON body pass through unchanged. +The middleware never changes a payload unless you set `stripRequestBodies`. +Pass `{ request: false }` to narrow the scope. +Or import a schema from `operationSchemas` for a single check. ## Operation metadata -The client exports an `OPERATIONS` map keyed by operationId — the same **operation descriptors** the runtime routes requests by, holding each operation's `method`, `path` template, `tags`, and wire shape: +The client exports an `OPERATIONS` map keyed by operationId. +These are the same **operation descriptors** that the runtime uses to route requests. +Each descriptor holds the operation's `method`, `path` template, `tags`, and wire shape: ```ts export const OPERATIONS = { @@ -608,26 +774,35 @@ export const OPERATIONS = { } as const satisfies Record; ``` -Because keys and values are plain string literals, they survive bundling/minification — making `OPERATIONS` the stable handle for cache keys, span names, or log labels (rather than `fn.name`, which a minifier can rename). -Every client method also carries its own identity as `client.getOrderById.operationId` — an explicit, minification-proof cache key for consumer wrappers (react-query keys and the like). +The keys and values are plain string literals, so they survive bundlers and minifiers. +Because of this, `OPERATIONS` is the stable handle for cache keys, span names, or log labels. +Do not use `fn.name`, because a minifier can rename it. +Every client method also carries its own identity as `client.getOrderById.operationId`. +This is an explicit cache key for consumer wrappers (react-query keys and the like), and a minifier cannot break it. The same `OperationId` / `OperationPath` / `OperationTag` unions type `ctx.operation` in middleware. ## Discriminated unions -A `oneOf` / `anyOf` with a usable discriminator gets an exported `is` type guard per member, taken from the description's `discriminator` or inferred when every member pins a shared property to a distinct string `const`: +A `oneOf` / `anyOf` with a usable discriminator gets an exported `is` type guard for each member. +The discriminator comes from the description's `discriminator`. +The generator can also infer it when every member sets a shared property to a distinct string `const`: ```ts export type MenuItem = Beverage | Dessert; export function isBeverage(value: MenuItem): value is Beverage { … } ``` -Guards are also emitted for unions nested inside another schema (array items, property values) as long as every member is a named schema. +The generator also emits guards for unions nested inside another schema (array items, property values), if every member is a named schema. A union without a usable discriminator gets no guard. ## Server-Sent Events -An operation whose `2xx` response declares `text/event-stream` is generated as a typed **async-generator function** (a client method plus the matching free function) — no flag required. -Each event's `data` is typed from the OpenAPI 3.2 `itemSchema` (falling back to the media `schema`, then `string`) and `JSON.parse`d when structured: +An operation whose `2xx` response declares `text/event-stream` generates as a typed **async-generator function**. +The output is a client method plus the matching free function. +No flag is required. +The `data` of each event is typed from the OpenAPI 3.2 `itemSchema`. +If `itemSchema` is absent, the type falls back to the media `schema`, then to `string`. +The client applies `JSON.parse` to structured data: ```ts import { streamMessages } from './client.ts'; @@ -637,24 +812,47 @@ for await (const ev of streamMessages()) { } ``` -The stream **auto-reconnects** on a dropped connection, resuming from the last event id via `Last-Event-ID` (backoff honors the server's `retry:`, then `reconnectDelay`, then 1s; capped at 30s). +The stream **reconnects automatically** after a dropped connection. +It resumes from the last event id with `Last-Event-ID`. +The backoff uses the server's `retry:`, then `reconnectDelay`, then 1 second, with a cap of 30 seconds. Tune per call with `{ reconnect: false }` or `{ reconnectDelay: 500 }`. -`break`ing the loop or aborting an `AbortSignal` ends it cleanly (no throw). +A `break` from the loop, or an aborted `AbortSignal`, ends the stream cleanly with no throw. SSE always throws `ApiError` on a non-2xx initial response, regardless of `--error-mode`. ## Pagination -Pagination is declared, never guessed: describe how your API paginates in `redocly.yaml` under `client.pagination`, or per operation with the `x-redoclyPagination` extension in the description. -The rule fields, the generate-time verification, and the precedence between the convention, `x-redoclyPagination`, and per-operation overrides are documented in the [`client.pagination` reference](../configuration/reference/client.md#pagination-object); there is no CLI flag. -Each paginated operation keeps its one-shot call and gains two async iterators — `.pages(args?, init?)` yielding full pages and `.items(args?, init?)` yielding individual items, typed statically from the response schema. - -Four styles are supported: -`cursor` sends the response's `nextCursor` back in `cursorParam`, stops when it's absent, `null`, or empty, and throws if the server returns the same cursor twice in a row. -For connection-style APIs whose cursor stays non-null on the last page, add the optional `hasMore` pointer (for example `/pageInfo/hasNextPage`) — iteration stops as soon as it resolves to `false`, skipping the follow-up empty request. -`offset` advances `offsetParam` by each page's item count, and `page` increments `offsetParam` by 1; both stop on an empty page. -`link` follows the response's RFC 8288 `Link` header `rel="next"` target (the GitHub pattern) — no advance parameter at all: the runtime merges the target's query params into the next call, so every page goes through the same declared endpoint (auth and middleware apply unchanged, and credentials are never handed to a cross-origin URL); iteration stops when no `rel="next"` is present and throws if the target repeats. -A `link` convention rule applies only to operations whose success response _documents_ a `Link` header; an explicit rule applies regardless but warns when the header is undocumented. -`limitParam` is optional metadata for any style: the iterator never sets it, so pass your page size in `params` yourself. +Pagination is declared, never guessed. +Describe how your API paginates in `redocly.yaml` under `client.pagination`. +Or declare it per operation with the `x-redoclyPagination` extension in the description. +The [`client.pagination` reference](../configuration/reference/client.md#pagination-object) documents the rule fields and the verification at generate time. +It also documents the precedence between the convention, `x-redoclyPagination`, and per-operation overrides. +There is no CLI flag. +Each paginated operation keeps its one-shot call and gains two async iterators. +`.pages(args?, init?)` yields full pages, and `.items(args?, init?)` yields individual items. +Both are typed statically from the response schema. + +The client supports four styles. +`cursor` sends the response's `nextCursor` back in `cursorParam`. +It stops when the cursor is absent, `null`, or empty. +It throws if the server returns the same cursor two times in a row. +Some connection-style APIs keep a non-null cursor on the last page. +For those, add the optional `hasMore` pointer (for example `/pageInfo/hasNextPage`). +Iteration stops as soon as the pointer resolves to `false`, and the client skips the empty follow-up request. + +`offset` advances `offsetParam` by the item count of each page. +`page` increments `offsetParam` by 1. +Both stop on an empty page. + +`link` follows the `rel="next"` target in the response's RFC 8288 `Link` header (the GitHub pattern). +There is no advance parameter. +The runtime merges the target's query parameters into the next call. +Because of this, every page goes through the same declared endpoint: auth and middleware apply unchanged, and the client never gives credentials to a cross-origin URL. +Iteration stops when no `rel="next"` is present, and it throws if the target repeats. +A `link` convention rule applies only to operations whose success response _documents_ a `Link` header. +An explicit rule applies in all cases, but it warns when the header is undocumented. + +`limitParam` is optional metadata for any style. +The iterator never sets it, so pass your page size in `params` yourself. ```ts import { client } from './client.ts'; @@ -669,10 +867,13 @@ for await (const page of client.listOrders.pages()) { ``` The flat free functions keep both iterators. -Note that the flat function itself takes positional arguments, but its `.pages`/`.items` always take the grouped shape — they are the client method's iterators. +The flat function itself takes positional arguments. +But its `.pages`/`.items` always take the grouped shape, because they are the client method's iterators. -Resume by passing the advance param in the initial args — iteration starts from there instead of the beginning. -Abort by passing an `AbortSignal`, forwarded to every page request: +To resume, pass the advance parameter in the initial args. +Iteration then starts from that point, not from the beginning. +To abort, pass an `AbortSignal`. +The client forwards it to every page request: ```ts const controller = new AbortController(); @@ -684,22 +885,38 @@ for await (const page of client.listOrders.pages( } ``` -A failed page always aborts iteration by throwing `ApiError`, even on an `--error-mode result` client. -On a result-mode client, `.pages()` yields raw pages rather than `{ data, error, response }` envelopes — only the one-shot call keeps the envelope — and the throw-mode-only `onError` middleware hook is not invoked. +A failed page always stops iteration with a thrown `ApiError`, even on an `--error-mode result` client. +On a result-mode client, `.pages()` yields raw pages, not `{ data, error, response }` envelopes. +Only the one-shot call keeps the envelope. +The client also does not invoke the `onError` middleware hook, which is throw-mode only. -For shapes the built-in styles don't cover — for example a cursor that travels in the request body or a header — page with a small hand-written helper over the generated call, which stays fully typed end to end (see the [`custom-pagination` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/custom-pagination)). +The built-in styles do not cover every shape, for example a cursor that travels in the request body or in a header. +For those shapes, write a small helper over the generated call. +The helper stays fully typed from end to end. +See the [`custom-pagination` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/custom-pagination). ## TanStack Query factories -The `tanstack-query` generator emits typed TanStack Query v5 factories per operation: - -- `Options(vars, init?)` per query (GET/HEAD) — pass to `useQuery`/`prefetchQuery`. Its `queryFn` forwards TanStack's abort `signal` into the request, so an unmounted or superseded query cancels its network call. -- `InfiniteOptions(vars, init?)` per **paginated** query — pass to `useInfiniteQuery`/`fetchInfiniteQuery`. The `initialPageParam`/`getNextPageParam` pair is compiled from the same [pagination](#pagination) rule that powers `.pages()`/`.items()`, including the `hasMore` stop, so infinite queries need no hand-written `getNextPageParam`. (`link`-style operations are the exception — their next page lives in a response header a `queryFn` cannot see; use the sdk's `.pages()`/`.items()` iterators for those.) -- `QueryKey(vars?)` — with `vars`, the exact key the options use; **without arguments, the invalidation prefix** that matches every cached page and filter of the operation: `queryClient.invalidateQueries({ queryKey: listOrdersQueryKey() })`. -- `Mutation(init?)` per mutation — per-call `RequestOptions` (headers, a retry override) reach the mutation's requests. +The `tanstack-query` generator emits typed TanStack Query v5 factories for each operation: + +- `Options(vars, init?)` for each query (GET/HEAD). + Pass it to `useQuery`/`prefetchQuery`. + Its `queryFn` forwards TanStack's abort `signal` into the request. + Because of this, an unmounted or superseded query cancels its network call. +- `InfiniteOptions(vars, init?)` for each **paginated** query. + Pass it to `useInfiniteQuery`/`fetchInfiniteQuery`. + The generator compiles the `initialPageParam`/`getNextPageParam` pair from the same [pagination](#pagination) rule that powers `.pages()`/`.items()`, and it includes the `hasMore` stop. + Because of this, infinite queries need no hand-written `getNextPageParam`. + `link`-style operations are the exception, because their next page lives in a response header that a `queryFn` cannot see. + Use the sdk's `.pages()`/`.items()` iterators for those. +- `QueryKey(vars?)`. + With `vars`, it returns the exact key that the options use. + **Without arguments, it returns the invalidation prefix** that matches every cached page and filter of the operation: `queryClient.invalidateQueries({ queryKey: listOrdersQueryKey() })`. +- `Mutation(init?)` for each mutation. + Per-call `RequestOptions` (headers, a retry override) reach the mutation's requests. The module-level factories bind the sdk's default `client`. -For an isolated instance (its own credentials, middleware, retry), build a bound set with `createQueryFactories`: +For an isolated instance with its own credentials, middleware, and retry, build a bound set with `createQueryFactories`: ```ts import { createClient } from '@redocly/client-generator'; @@ -712,17 +929,23 @@ const internal = createQueryFactories( useQuery(internal.getOrderOptions({ orderId })); ``` -When several generated APIs share one `QueryClient`, their operationIds can collide (two APIs with a `check` operation would mix caches). -Set `queryKeyPrefix` in the `client` block to namespace every key: `queryKeyPrefix: main` makes the keys `['main', 'check', vars]`. +When several generated APIs share one `QueryClient`, their operationIds can collide. +For example, two APIs with a `check` operation would mix caches. +Set `queryKeyPrefix` in the `client` block to add a namespace to every key. +For example, `queryKeyPrefix: main` makes the keys `['main', 'check', vars]`. ## Format and lint the generated files -The generator prints one canonical style — the TypeScript compiler's printer (four-space indent, double quotes). -If your project's formatter enforces a different style, its check fails on freshly generated files. -Either run your formatter over the output right after generating (for example, as the next step in the same script), or add the generated paths to your formatter's ignore list — generated files are not hand-edited, so reformatting them is churn without review value. +The generator prints one canonical style: the TypeScript compiler's printer, with a four-space indent and double quotes. +If your project's formatter enforces a different style, its check fails on newly generated files. +Run your formatter over the output immediately after generation, for example as the next step in the same script. +Or add the generated paths to your formatter's ignore list. +Generated files are not edited by hand, so a reformat is churn without review value. -Linting is different: the generated code is expected to pass strict lint configurations as-is (no `any`, no non-null assertions, no unused imports). -If your linter flags generated output, [report it](https://github.com/Redocly/redocly-cli/issues) — that is a generator bug, not a style choice. +Linting is different. +The generated code must pass strict lint configurations unchanged: no `any`, no non-null assertions, and no unused imports. +If your linter flags generated output, [report it](https://github.com/Redocly/redocly-cli/issues). +That is a generator bug, not a style choice. ## Resources diff --git a/docs/@v2/usage-data.md b/docs/@v2/usage-data.md index a7d26fca66..f5fed93166 100644 --- a/docs/@v2/usage-data.md +++ b/docs/@v2/usage-data.md @@ -5,33 +5,36 @@ seo: # Usage data and product metrics -Redocly CLI sends a small set of anonymized data to help us understand how the tool is used and improve it. +The Redocly CLI sends a small set of anonymized data to Redocly. +We use this data to understand how you use the tool and to improve it. ## What data is collected -When a command is run, the following data is collected: +When you run a command, the CLI collects this data: -- the command being run -- command exit code -- whether the user is logged into Redocly -- values from `REDOCLY_ENVIRONMENT`, `REDOCLY_CLI_TELEMETRY_METADATA`, and `CI` environment variables -- CLI version -- Node.js and NPM versions +- the command that you run +- the command exit code +- whether the user is logged in to Redocly +- the values of the `REDOCLY_ENVIRONMENT`, `REDOCLY_CLI_TELEMETRY_METADATA`, and `CI` environment variables +- the CLI version +- the Node.js and NPM versions - whether the `redocly.yaml` configuration file exists -- API specification type and version -- names of lint rules that reported errors, warnings, or ignored problems -- Arazzo x-security authentication types -- for `generate-client`: which built-in generators ran, the count of custom generators, which of the package's own exported helper names a custom generator imports, how many APIs a composed CLI entry (`client.cliOutput`) spanned, and a coarse error category on failure. - When a path-loaded generator carries the `eject-generator` provenance header, its built-in origin and the version it was ejected from are included (for example `php@0.2.0`) — the file's contents, path, and any user-chosen names are never transmitted. -- for `eject-generator`: the action (`eject`, `update`, `guidance`), the built-in generator name, and a coarse outcome category (such as `success`, `conflicts` with the conflict count, `already-exists`, or `merge-tool-missing`). - An `--update` run also includes the two `@redocly/client-generator` versions involved: the one the file was ejected from and the installed one. - Custom generator file contents, paths, and names are never collected. -- platform (Linux, macOS, Windows) -- anonymous ID (a randomly generated identifier that doesn't contain personal information) -- command execution time -- whether the CLI runs from a released build or development build - -Values such as file names, organization IDs, and URLs are removed, replaced by just "URL" or "file", etc. +- the API specification type and version +- the names of the lint rules that report errors, warnings, or ignored problems +- the Arazzo x-security authentication types +- for `generate-client`: the built-in generators that run, the count of custom generators, the names of the package's own exported helpers that a custom generator imports, the count of APIs that a composed CLI entry (`client.cliOutput`) spans, and a coarse error category if the command fails. + If a path-loaded generator has the `eject-generator` provenance header, the CLI also sends the built-in origin and the version that the generator was ejected from (for example `php@0.2.0`). + The CLI never sends the file contents, the file path, or names that the user chose. +- for `eject-generator`: the action (`eject`, `update`, `guidance`), the name of the built-in generator, and a coarse outcome category (such as `success`, `conflicts` with the conflict count, `already-exists`, or `merge-tool-missing`). + For an `--update` run, the CLI also sends the two `@redocly/client-generator` versions: the version that the file was ejected from, and the installed version. + The CLI never collects the file contents, paths, or names of custom generators. +- the platform (Linux, macOS, Windows) +- an anonymous ID (a randomly generated identifier that contains no personal information) +- the command execution time +- whether the CLI runs from a released build or a development build + +The CLI removes values such as file names, organization IDs, and URLs. +The CLI replaces these values with generic words such as "URL" or "file". ## Opt out of data collection From c7144379cc8d516d1145830b321f78696d3787c3 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 11 Aug 2026 18:08:19 +0300 Subject: [PATCH 156/211] Update .github/workflows/tests.yaml Co-authored-by: Andrew Tatomyr --- .github/workflows/tests.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 02fb25a7b6..e36a6a66c2 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -38,9 +38,6 @@ jobs: e2e: # Everything under tests/e2e EXCEPT generate-client, which has its own job below. - # Unsharded: the mid-run cancellations that forced sharding were traced to the - # generate-client compile bars, which now run in their own job — if this job ever gets - # cancelled mid-run again, reintroduce the shard matrix. runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 From cb2d497589aefc29c527db59feb9c49f2f19d4bc Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 11 Aug 2026 18:11:33 +0300 Subject: [PATCH 157/211] fix(cli): state the composition and toolkit-version comments positively --- packages/cli/src/commands/eject-generator.ts | 4 ++-- packages/cli/src/commands/generate-client.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts index e8e93b414e..5527f6ee9b 100644 --- a/packages/cli/src/commands/eject-generator.ts +++ b/packages/cli/src/commands/eject-generator.ts @@ -388,8 +388,8 @@ export const handleEjectGenerator = async ({ const assetsDir = ejectAssetsDir(); const asset = readFileSync(join(assetsDir, 'generators', `${name}.mjs`), 'utf-8'); - // The version that matters is the TOOLKIT's (what the ejected file records and imports), - // not the CLI's — they version independently. + // 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`); diff --git a/packages/cli/src/commands/generate-client.ts b/packages/cli/src/commands/generate-client.ts index 7076b0617a..0c3d9a1029 100644 --- a/packages/cli/src/commands/generate-client.ts +++ b/packages/cli/src/commands/generate-client.ts @@ -172,8 +172,8 @@ export async function handleGenerateClient({ config: aliasConfig, configDir, }); - // The emitted module, not the config string, decides what composes: `cli` also - // arrives as an ejected path entry or as another generator's prerequisite. + // The emitted module decides what composes: `cli` reaches a run as a built-in + // name, an ejected path entry, or another generator's prerequisite. const cliModule = result.files.find((file) => file.path.endsWith('.cli.ts')); if (cliModule !== undefined) { const importExt = clientConfig.importExt ?? 'js'; From 172496979f1221f0973c2e16927829cdba0fa20c Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Thu, 13 Aug 2026 13:49:59 +0300 Subject: [PATCH 158/211] fix: rename the sdk generator to typescript and address review feedback (eject config wiring, cliOutput validation, cli-docs --token, sample group slugs, docs wording) --- .claude/skills/redocly-cli/SKILL.md | 2 +- .claude/skills/rules-system/SKILL.md | 178 ++++++++++++++++++ docs/@v2/commands/eject-generator.md | 5 +- docs/@v2/commands/generate-client.md | 6 +- docs/@v2/configuration/reference/client.md | 10 +- .../@v2/guides/customize-client-generation.md | 14 +- docs/@v2/guides/use-generated-client.md | 43 +++-- .../client-generator-telemetry.test.ts | 6 +- .../commands/eject-generator.test.ts | 55 +++++- packages/cli/src/commands/eject-generator.ts | 47 +++-- packages/cli/src/commands/generate-client.ts | 19 +- packages/cli/src/index.ts | 2 +- .../src/utils/client-generator-telemetry.ts | 2 +- packages/client-generator/ARCHITECTURE.md | 4 +- packages/client-generator/CONTEXT.md | 10 +- packages/client-generator/README.md | 4 +- .../client-generator/eject-assets/AGENTS.md | 4 +- .../skills/cli-docs-generator/SKILL.md | 2 +- .../skills/cli-generator/SKILL.md | 2 +- .../skills/client-generators/SKILL.md | 4 +- .../skills/swr-generator/SKILL.md | 2 +- .../skills/tanstack-query-generator/SKILL.md | 2 +- .../skills/transformers-generator/SKILL.md | 2 +- .../SKILL.md | 16 +- .../scripts/generate-eject-assets.mjs | 7 +- .../src/__tests__/index.test.ts | 6 +- .../src/__tests__/pipeline-ts-free.test.ts | 4 +- .../src/__tests__/plugin.test.ts | 2 +- .../client-generator/src/emitters/cli-docs.ts | 5 +- .../src/emitters/tanstack-query.ts | 2 +- .../src/generators/__tests__/cli-docs.test.ts | 19 +- .../src/generators/__tests__/cli.test.ts | 35 +++- .../__tests__/fixtures/route-map-plugin.ts | 2 +- .../__tests__/generator-skills.test.ts | 2 +- .../src/generators/__tests__/index.test.ts | 45 +++-- .../src/generators/__tests__/resolve.test.ts | 37 ++-- .../__tests__/tanstack-query.test.ts | 2 +- .../{sdk.test.ts => typescript.test.ts} | 12 +- .../src/generators/cli-docs/AGENTS.md | 2 +- .../src/generators/cli/AGENTS.md | 2 +- .../src/generators/cli/index.ts | 7 +- .../client-generator/src/generators/index.ts | 4 +- .../client-generator/src/generators/meta.ts | 38 ++-- .../src/generators/swr/AGENTS.md | 2 +- .../src/generators/swr/index.ts | 2 +- .../src/generators/tanstack-query/AGENTS.md | 2 +- .../src/generators/tanstack-query/index.ts | 2 +- .../src/generators/transformers/AGENTS.md | 2 +- .../client-generator/src/generators/types.ts | 6 +- .../generators/{sdk => typescript}/AGENTS.md | 4 +- .../generators/{sdk => typescript}/index.ts | 4 +- packages/client-generator/src/pipeline.ts | 6 +- packages/client-generator/src/plugin.ts | 4 +- packages/client-generator/src/types.ts | 4 +- tests/e2e/generate-client/cli-compose.test.ts | 43 ++++- tests/e2e/generate-client/cli.test.ts | 4 +- tests/e2e/generate-client/eject.test.ts | 8 +- tests/e2e/generate-client/examples/README.md | 50 ++--- .../examples/baked-setup/redocly.yaml | 2 +- .../generate-client/examples/cli/redocly.yaml | 2 +- .../configure-and-middleware/redocly.yaml | 2 +- .../examples/custom-generator/README.md | 4 +- .../examples/custom-generator/redocly.yaml | 2 +- .../custom-generator/route-map-generator.mjs | 6 +- .../examples/custom-generator/src/main.ts | 2 +- .../examples/custom-pagination/redocly.yaml | 2 +- .../.claude/skills/client-generators/SKILL.md | 4 +- .../examples/fetch-functions/README.md | 2 +- .../examples/fetch-functions/redocly.yaml | 2 +- .../generate-client/examples/mock/README.md | 2 +- .../examples/mock/redocly.yaml | 2 +- .../generate-client/examples/mock/src/node.ts | 2 +- .../examples/multi-instance/redocly.yaml | 2 +- .../examples/nested-facade/README.md | 2 +- .../nested-facade/nested-facade-generator.mjs | 6 +- .../examples/nested-facade/redocly.yaml | 2 +- .../examples/nested-facade/src/main.ts | 2 +- .../examples/node-native/redocly.yaml | 2 +- .../examples/package-runtime/redocly.yaml | 2 +- .../examples/pagination/redocly.yaml | 2 +- .../examples/programmatic/generate.ts | 2 +- .../examples/sse-streaming/redocly.yaml | 2 +- .../examples/tanstack-query/README.md | 2 +- .../examples/tanstack-query/redocly.yaml | 2 +- .../typescript-types-generator/README.md | 2 +- .../typescript-types-generator/redocly.yaml | 2 +- .../response-map-generator.mjs | 6 +- .../typescript-types-generator/src/main.ts | 2 +- .../examples/vendored-edge/redocly.yaml | 2 +- .../zero-install-quickstart/redocly.yaml | 2 +- .../generate-client/examples/zod/README.md | 2 +- .../generate-client/examples/zod/redocly.yaml | 2 +- .../fixtures/route-map-plugin.mjs | 2 +- .../generator-contract.test.ts | 8 +- .../large-descriptions.test.ts | 4 +- tests/e2e/generate-client/mock.test.ts | 8 +- .../e2e/generate-client/package-mode.test.ts | 4 +- tests/e2e/generate-client/plugin.test.ts | 8 +- .../generate-client/redocly-config.test.ts | 48 ++--- tests/e2e/generate-client/swr.test.ts | 2 +- .../tanstack-query.runtime.test.ts | 4 +- .../generate-client/tanstack-query.test.ts | 6 +- .../e2e/generate-client/transformers.test.ts | 8 +- tests/e2e/generate-client/zod.test.ts | 4 +- 104 files changed, 683 insertions(+), 324 deletions(-) create mode 100644 .claude/skills/rules-system/SKILL.md rename packages/client-generator/eject-assets/skills/{sdk-generator => typescript-generator}/SKILL.md (81%) rename packages/client-generator/src/generators/__tests__/{sdk.test.ts => typescript.test.ts} (92%) rename packages/client-generator/src/generators/{sdk => typescript}/AGENTS.md (95%) rename packages/client-generator/src/generators/{sdk => typescript}/index.ts (92%) diff --git a/.claude/skills/redocly-cli/SKILL.md b/.claude/skills/redocly-cli/SKILL.md index 2733049c93..fc3facff9b 100644 --- a/.claude/skills/redocly-cli/SKILL.md +++ b/.claude/skills/redocly-cli/SKILL.md @@ -126,7 +126,7 @@ Configure it durably under a `client` block in `redocly.yaml` instead of flags: ```yaml client: - generators: [sdk, zod] # add-ons: tanstack-query, swr, mock, transformers, or a plugin path + generators: [typescript, zod] # add-ons: tanstack-query, swr, mock, transformers, or a plugin path outputMode: split pagination: # config-only, no CLI flag style: cursor diff --git a/.claude/skills/rules-system/SKILL.md b/.claude/skills/rules-system/SKILL.md new file mode 100644 index 0000000000..2ba3f18d82 --- /dev/null +++ b/.claude/skills/rules-system/SKILL.md @@ -0,0 +1,178 @@ +--- +name: rules-system +description: How to write built-in lint rules and decorators for packages/core — the Walker/Visitors/Nodes pattern, visitor hooks, the ctx object, rule registration, and stateful rule examples. Use when adding or changing a rule, decorator, or preprocessor. +--- + +## Rules System: Walker, Visitors, and Nodes + +This is the most important pattern to understand when working in `packages/core`. + +### Concepts + +Node — a typed object in the parsed API description AST. +Every node has a name that matches its spec concept: `Schema`, `Operation`, `Server`, `Parameter`, `Response`, etc. +The full list of node types per spec is in `packages/core/src/types/`. + +Visitor — an object whose keys are node names. +When the Walker enters or leaves a node of that type, it calls the corresponding visitor hook. +Visitor names mirror node names exactly. +The full visitor type map is in `packages/core/src/visitors.ts`. + +Walker — the engine in `packages/core/src/walk.ts` (`walkDocument`). +It recursively traverses the parsed document, resolves `$ref` references, and invokes registered visitors at each node. + +### Visitor hooks + +Each key in a visitor object can be either a plain function (shorthand for `enter`) or an object with up to three hooks: + +| Hook | When it runs | +| ------------------ | -------------------------------------------------------------------------------- | +| `enter(node, ctx)` | When the Walker first arrives at this node | +| `leave(node, ctx)` | After all child nodes have been visited; all `$ref`s are resolved by this point | +| `skip(node, ctx)` | Called before `enter`; return `true` to skip this visitor entirely for this node | + +### Context object (`ctx`) + +Every visitor hook receives a context object with: + +| Property | Type | Description | +| ------------------ | -------------------- | ------------------------------------------------------- | +| `report(problem)` | function | Emit a lint problem | +| `location` | `Location` | JSON pointer + source of the current node | +| `key` | `string \| number` | Key of this node within its parent | +| `parent` | `any` | Parent node object | +| `resolve(ref)` | function | Resolve a `$ref` to its target node and location | +| `type` | `NormalizedNodeType` | Type descriptor for the current node | +| `specVersion` | `SpecVersion` | For example, `'OAS3_0'`, `'OAS3_1'` | +| `getVisitorData()` | function | Shared data store scoped to the current rule invocation | + +### Rule function signature + +A rule is a factory function that receives rule options and returns a visitor object. +The type depends on the target spec: + +```ts +import type { Oas3Rule } from '../../visitors.js'; + +// Factory receives rule options, returns a visitor +export const MyRule: Oas3Rule = (options) => { + // State can be kept here — it is scoped to one document walk + return { + NodeName(node, ctx) { + /* shorthand enter */ + }, + + OtherNode: { + enter(node, ctx) { + /* ... */ + }, + leave(node, ctx) { + /* ... */ + }, + skip(node, ctx) { + return false; + }, + }, + }; +}; +``` + +Available rule types: `Oas3Rule`, `Oas3_1Rule`, `Oas2Rule`, `Async2Rule`, `Async3Rule`, `ArazzoRule`. + +### Minimal rule example + +```ts +// packages/core/src/rules/oas3/no-server-trailing-slash.ts +import type { Oas3Rule } from '../../visitors.js'; + +export const NoServerTrailingSlash: Oas3Rule = () => { + return { + Server(server, { report, location }) { + if (server.url?.endsWith('/') && server.url !== '/') { + report({ + message: 'Server `url` should not have a trailing slash.', + location: location.child(['url']), + }); + } + }, + }; +}; +``` + +### Stateful rule example (using `enter` + `leave`) + +```ts +// packages/core/src/rules/oas3/no-unused-components.ts +import type { Oas3Rule } from '../../visitors.js'; + +export const NoUnusedComponents: Oas3Rule = () => { + const components = new Map(); + + return { + // Track every $ref resolution — mark the target as used + ref(ref, { type, resolve, key, location }) { + const resolved = resolve(ref); + if (resolved.location) { + components.set(resolved.location.absolutePointer, { + used: true, + name: key.toString(), + location, + }); + } + }, + + // Report unused components only after the entire document has been walked + Root: { + leave(_, { report }) { + components.forEach((info) => { + if (!info.used) { + report({ + message: `Component: "${info.name}" is never used.`, + location: info.location.key(), + }); + } + }); + }, + }, + + NamedSchemas: { + Schema(schema, { location, key }) { + components.set(location.absolutePointer, { used: false, location, name: key.toString() }); + }, + }, + }; +}; +``` + +### Register a new rule + +After creating the rule file, register it in the spec index: + +```ts +// packages/core/src/rules/oas3/index.ts +import { NoMyRule } from './no-my-rule.js'; + +export const Oas3Rules = { + // ...existing rules... + 'no-my-rule': NoMyRule, +}; +``` + +### Configurable rules (Assertions) + +Users can define their own rules in `redocly.yaml` using the built-in `Assertion` system (`packages/core/src/rules/common/assertions/asserts.ts`). +Instead of writing TypeScript, the user declares a subject node type and a set of assertion checks. +Internally, the subject type is converted into a visitor automatically. + +```yaml +rules: + rule/path-exclude-pattern: + subject: + type: Paths # node type → becomes a visitor + assertions: + notPattern: \/wrong +``` + +Prefer implementing actual rule code over adding assertion-based rules when contributing to the core rule set. + +--- diff --git a/docs/@v2/commands/eject-generator.md b/docs/@v2/commands/eject-generator.md index e9be8ce0bf..dc42ef1123 100644 --- a/docs/@v2/commands/eject-generator.md +++ b/docs/@v2/commands/eject-generator.md @@ -8,7 +8,7 @@ The _generated_ client stays generated and reproducible, so do not edit it manua You or your agent edit the generator, and the `redocly generate-client` command rebuilds the client. When the spec changes later, the command regenerates the client and keeps your customization. -You can eject every built-in generator: the language SDKs (`python`, `go`, `php`), the TypeScript `sdk`, and the other generators (`zod`, `mock`, `cli`, `cli-docs`, `swr`, `tanstack-query`, `transformers`). +You can eject every built-in generator: the SDKs (`typescript`, `python`, `go`, `php`) and the add-on generators (`zod`, `mock`, `cli`, `cli-docs`, `swr`, `tanstack-query`, `transformers`). The `tanstack-query-vue`, `-svelte`, and `-solid` variants are the same generator with one different argument. Eject `tanstack-query` and set the framework in your copy. @@ -63,7 +63,8 @@ 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, where a path entry replaces the built-in name. +It also points your config at the ejected file: in `client.generators`, the path to your copy replaces the built-in name. +If the config has no `client.generators` list yet, the command adds one. ```yaml client: diff --git a/docs/@v2/commands/generate-client.md b/docs/@v2/commands/generate-client.md index 49a80ad9a4..7e6550bff1 100644 --- a/docs/@v2/commands/generate-client.md +++ b/docs/@v2/commands/generate-client.md @@ -48,7 +48,7 @@ redocly generate-client [--help] [--version] | `--output-mode` | string | The file layout. See [Choose an output mode](#choose-an-output-mode).
**Possible values:** `single`, `split`. Default value is `single`. | | `--runtime` | string | The location of the client engine. See [Choose a runtime](#choose-a-runtime).
**Possible values:** `inline`, `package`. Default value is `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 value is `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 `sdk`. See [Generators](../guides/use-generated-client.md#generators) for the full list. | +| `--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:** `flat`, `grouped`. Default value is `flat`. | | `--error-mode` | string | Sets how operations report HTTP errors. See [Error handling](../guides/use-generated-client.md#error-handling).
**Possible values:** `throw`, `result`. Default value is `throw`. | | `--date-type` | string | The type of the `date`/`date-time` fields. If you use `Date`, also use the `transformers` generator.
**Possible values:** `string`, `Date`. Default value is `string`. | @@ -56,7 +56,7 @@ redocly generate-client [--help] [--version] | `--mock-seed` | number | The seed for `faker`-mode mocks. Use it to get reproducible data. The command ignores it in `static` mode. | | `--server-url` | string | Overrides the default server URL in the client. The option accepts an absolute URL (`https://api.example.com`) or a relative URL (`/v1`). Defaults to `servers[0].url`. The app can also change the server URL at runtime with `createClient({ serverUrl })` or `configure({ serverUrl })`. See [Authentication](../guides/use-generated-client.md#authentication) in the usage guide. | | `--setup` | string | The path to a publisher setup module that the command includes in the client. Use it to pre-configure defaults, for example the server URL, retries, headers, and middleware. A published SDK then contains these defaults. See [Publisher defaults](../guides/customize-client-generation.md#publisher-defaults). | -| `--bin-name` | string | The command name that the `cli` generator prints in the help output. The generator also uses it to derive the names of the credential environment variables. Defaults to the output stem with non-word characters converted to `-`. | +| `--bin-name` | string | The command name that the `cli` generator prints in the help output. The generator also uses it to derive the names of the credential environment variables. Defaults to the output file name (without extension) with non-word characters converted to `-`. | | `--go-package` | string | The package clause in the output of the `go` generator. It must be a valid Go package name (lowercase letters, digits, and `_`; it must not start with a digit or be a keyword). Default value is `client`. | | `--config` | string | Specify the path to the [configuration file](#generate-from-the-configuration-file). | | `--help` | boolean | Show help. | @@ -76,7 +76,7 @@ Declare it only as the [`client.pagination`](../configuration/reference/client.m ```yaml client: generators: - - sdk + - typescript apis: cafe: root: ./openapi.yaml diff --git a/docs/@v2/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md index eb0c894a5b..97fcec2745 100644 --- a/docs/@v2/configuration/reference/client.md +++ b/docs/@v2/configuration/reference/client.md @@ -24,7 +24,7 @@ As an alternative, pass `pagination` to the programmatic `generateClient(...)`. | Option | Type | Description | | ---------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `generators` | [string] | The generators to run, in order. Each entry is a built-in name (`sdk`, `zod`, `tanstack-query` or its `-vue`/`-svelte`/`-solid` variants, `swr`, `mock`, `transformers`, `cli`, `cli-docs`, `python`, `go`, `php`), or the path or package name of a custom generator. | +| `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`, `cli-docs`, `python`, `go`, `php`), or the path or package name of a custom generator. | | `outputMode` | string | The file layout: `single` or `split`. This option applies to TypeScript output only. The `python`, `go`, and `php` SDKs always emit one self-contained file. | | `runtime` | string | The runtime distribution: `inline` or `package`. This option applies to TypeScript output only. The `python`, `go`, and `php` SDKs always embed their runtime. | | `importExt` | string | The extension in generated relative imports: `js` (default, for tsc and bundlers) or `ts` (for Node's built-in type stripping). This option applies to TypeScript output only. | @@ -34,10 +34,10 @@ As an alternative, pass `pagination` to the programmatic `generateClient(...)`. | `mockData` | string | The data mode for the `mock` generator: `static` or `faker`. | | `mockSeed` | number | The seed for mocks in `faker` mode. | | `queryKeyPrefix` | string | The first element of every `tanstack-query` query key and mutation key. It separates the cache entries when several generated APIs share one QueryClient. This option is available only in the configuration file and has no flag. | -| `codeSamples` | boolean | Emit `.code-samples.yaml`. This file is an OpenAPI Overlay that adds `x-codeSamples` to each operation. The samples come from each selected generator that implements `sample()`. This option is available only in the configuration file and has no flag. | +| `codeSamples` | boolean | Emit `.code-samples.yaml` next to the client. This file is an OpenAPI Overlay that adds `x-codeSamples` to each operation. The samples come from each selected generator that implements `sample()`. This option is available only in the configuration file and has no flag. | | `serverUrl` | string | The server URL that the client includes as its default. If you do not set it, the client uses `servers[0].url`. | | `goPackage` | string | The package clause for the output of the `go` generator. The value must be a valid Go package name: lowercase letters, digits, and `_`, with no digit at the start, and not a keyword. An invalid value stops generation, so the generator does not emit a file that Go cannot compile. Default `client`. | -| `binName` | string | The command name that the `cli` generator shows in the help output and uses to derive its credential environment variables. The default is the output stem, sanitized. | +| `binName` | string | The command name that the `cli` generator shows in the help output and uses to derive its credential environment variables. The default is the output file name (without extension), sanitized. | | `cliOutput` | string | The path of a composed CLI entry. The entry includes every api that emits a cli module: from the `cli` generator by name, ejected, or included as a prerequisite. The result is one binary. You address each api by its alias, and each api has `__*` credential variables. This option is available only in the top-level `client` block. See [Compose and extend the CLI](../../guides/use-generated-client.md#compose-and-extend-the-cli). | | `options` | object | Options for each generator, keyed by generator name. The command validates each entry against the schema that the generator declares. See [Custom generators](../../guides/customize-client-generation.md#custom-generators). | | `setup` | string | The path to a publisher setup module that the client includes. The module sets defaults such as the server URL, retries, headers, and middleware. See [Publisher defaults](../../guides/customize-client-generation.md#publisher-defaults). | @@ -83,7 +83,7 @@ CLI flags override the resolved configuration. ```yaml client: generators: - - sdk + - typescript argsStyle: flat apis: cafe: @@ -91,7 +91,7 @@ apis: clientOutput: ./src/api/client.ts client: # replaces the top-level block for this API generators: - - sdk + - typescript - zod argsStyle: grouped orders: diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index 55e32f1dbe..df2e005b0f 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -79,7 +79,7 @@ The quickest method to get a customized generator is [`redocly eject-generator `](../commands/eject-generator.md). The command copies any built-in generator into `./generators/` as an editable file that you own. An ejected generator with no changes produces byte-identical output. -The path entry replaces the built-in name, so regeneration continues to work after each customization. +In `client.generators`, the path to your copy replaces the built-in name, so `redocly generate-client` keeps working and now runs your version. [`--update`](../commands/eject-generator.md#update-an-ejected-generator) merges later built-in versions into your copy. The eject command also writes the generator's design as an agent skill (`.claude/skills/-generator/SKILL.md`). @@ -203,7 +203,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. -`tsType` is the schema-to-type renderer that the built-in sdk itself uses. +`tsType` is the schema-to-type renderer that the built-in `typescript` generator itself uses. Because of this, the mapping (refs, arrays, unions, formats, parenthesization) is exactly the same as in the generated client: ```js @@ -211,7 +211,7 @@ import { tsType } from '@redocly/client-generator/generate'; export default { name: 'response-map', - requires: ['sdk'], + requires: ['typescript'], run({ model, outputPath }) { const members = model.services .flatMap((service) => service.operations) @@ -242,7 +242,7 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript - ./tools/response-map-generator.ts # local path (resolved against redocly.yaml) - '@acme/openapi-valibot' # published package ``` @@ -257,7 +257,7 @@ await generateClient({ api: './openapi.yaml', output: './src/api/client.ts', customGenerators: [responseMap], - generators: ['sdk', 'response-map'], + generators: ['typescript', 'response-map'], }); ``` @@ -265,11 +265,11 @@ await generateClient({ A generator that can call an operation can also document the operation. Implement the optional `sample(operation, ctx)` hook to return one idiomatic snippet (`{ lang, label, source }`) for each operation. -With `codeSamples: true` in the `client` block, generation collects the samples of every selected generator into `.code-samples.yaml`. +With `codeSamples: true` in the `client` block, generation collects the samples of every selected generator into `.code-samples.yaml`. This file is an [OpenAPI Overlay](https://spec.openapis.org/overlay/latest.html) that adds `x-codeSamples` to each operation. Docs tooling can apply the file. -The built-in `sdk` generator includes the TypeScript reference implementation. +The built-in `typescript` generator is the reference implementation. If you only set the flag, your Redoc docs get a TypeScript example for each operation. These examples always agree with the SDK. diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index b91762e69c..99d7fc5ea8 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -7,15 +7,15 @@ To change what the command generates (publisher defaults, custom generators), se ## Generators -The `--generator` option selects the output (default `sdk`). -Each non-`sdk` generator adds a standalone module next to the client. +The `--generator` option selects the output (default `typescript`). +Each non-`typescript` generator adds a standalone module next to the client. The client never imports this module. Because of this, an add-on never adds a dependency to the client. Incompatible selections fail immediately with an explanation. | Generator | Emits | App peer dependency | | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------- | -| `sdk` | The typed client (default). | none | +| `typescript` | The typed client (default). | none | | `zod` | `.zod.ts`: [Zod](https://zod.dev) schemas and [validation middleware](#runtime-validation). | `zod` `^3.23 \|\| ^4` | | `tanstack-query` | `.tanstack.ts`: [TanStack Query](https://tanstack.com/query) v5 [factories](#tanstack-query-factories), with `InfiniteOptions` for paginated operations. React by default; `tanstack-query-vue`/`-svelte`/`-solid` change the adapter import. | `@tanstack/-query` `^5` | | `swr` | `.swr.ts`: [SWR](https://swr.vercel.app) hooks. | `swr` `^2` | @@ -25,29 +25,29 @@ Incompatible selections fail immediately with an explanation. | `cli-docs` | `.cli.md`: a Markdown [reference for the generated CLI](#cli-reference-docs). It lists every command, flag, exit code, and credential variable. | none | ```sh -redocly generate-client openapi.yaml --output src/client.ts --generator sdk --generator zod --generator mock +redocly generate-client openapi.yaml --output src/client.ts --generator typescript --generator zod --generator mock ``` -`tanstack-query`, `swr`, and `cli` wrap the throw-mode `sdk` client. +`tanstack-query`, `swr`, and `cli` wrap the throw-mode `typescript` client. Because of this, they require `--error-mode throw`. The `transformers` generator requires `--date-type Date`. See the [`zod`](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/zod), [`tanstack-query`](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/tanstack-query), and [`mock`](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/mock) examples. ### Generated CLI -The `cli` generator emits `.cli.ts`. +The `cli` generator emits `.cli.ts`, the CLI module next to the client (`client.cli.ts` for `client.ts`). This file is a zero-dependency command-line interface for the generated client, ready to use as a bin. Path parameters are positional. Query parameters become typed `--kebab-name` flags. Enum flags list their choices in `--help`, and array parameters repeat the flag. Supply a JSON request body with `--json ''`, `--json @file.json`, or `--json @-` (stdin). The CLI validates each request before it sends it. -When you select `cli`, the command also selects the generators it needs (`sdk` and `zod`), so you do not have to list them. +When you select `cli`, the command also selects the generators it needs (`typescript` and `zod`), so you do not have to list them. Because of this, the CLI validation uses [zod](https://zod.dev/) at runtime. Install zod next to the generated CLI (`npm i zod`). ```sh -redocly generate-client openapi.yaml --output src/client.ts --generator sdk --generator cli +redocly generate-client openapi.yaml --output src/client.ts --generator typescript --generator cli npx tsx src/client.cli.ts orders listOrders --status open --limit 10 npx tsx src/client.cli.ts orders createOrder --json @order.json npx tsx src/client.cli.ts orders listOrders --page-all # one JSON page per line @@ -70,7 +70,7 @@ You can search for `listOrders` in your API description, in your SDK, and in you The top-level help shows every global flag under `Global flags:`: `--server-url`, `--format json|ndjson`, `--dry-run`, `--page-all`, `--output`, `--token`, and `--json`. The same section shows the environment variables that the CLI reads. -The CLI reads credentials from environment variables, with a prefix derived from the file stem in constant case. +The CLI reads credentials from environment variables, with a prefix derived from the output file name in constant case (`MY_API_*` for `my-api.ts`; `binName` overrides it). For bearer auth, use `_TOKEN` (or `--token`). For basic auth, use `_USERNAME` and `_PASSWORD`. For apiKey auth, use `_API_KEY_`. @@ -110,24 +110,26 @@ This makes two things possible without changes to the generated files. **One binary for several APIs.** Set a top-level `client.cliOutput`. Then `redocly generate-client` (no api argument) emits a composed entry for every api that emits a cli module. -Each api uses its alias from `apis:` as its namespace, and it reads credentials under `__*`: +Each api's alias from `apis:` (`shop` and `kitchen` below) becomes its command namespace, and its credentials are read under `__*`: ```yaml client: binName: cafe cliOutput: ./src/cafe.ts - generators: [sdk, cli] + generators: [typescript, cli] apis: shop: { root: ./shop/openapi.yaml, clientOutput: ./src/shop.ts } kitchen: { root: ./kitchen/openapi.yaml, clientOutput: ./src/kitchen.ts } ``` ```sh -cafe shop listOrders --limit 3 # CAFE_SHOP_TOKEN -cafe kitchen createOrder --json @o.json # CAFE_KITCHEN_TOKEN +npx tsx src/cafe.ts shop listOrders --limit 3 # CAFE_SHOP_TOKEN +npx tsx src/cafe.ts kitchen createOrder --json @o.json # CAFE_KITCHEN_TOKEN ``` -If two descriptions have the same operationId, the result is two different commands. +`binName` is the name the help output prints and the prefix of the credential variables — it does not install a `cafe` executable. +To type `cafe` instead of `npx tsx src/cafe.ts`, compile the entry and point the `bin` field of `package.json` at it, as described at the end of this section. +The alias namespace exists because operationIds are only unique within one description: if two descriptions declare the same operationId, the result is two different commands. Each api keeps its own server URL, schemes, and credentials. **Commands the description doesn't have.** @@ -168,7 +170,7 @@ Then point the `bin` field of `package.json` at the compiled file. #### CLI reference docs -The `cli-docs` generator writes `.cli.md`, a Markdown reference. +The `cli-docs` generator writes `.cli.md`, a Markdown reference. The page contains the usage line, the global flags, the credential environment variables, and the exit-code table. It also contains one section for each command. Each section lists the positionals and flags of the command with their types, defaults, and descriptions. @@ -209,7 +211,7 @@ The `run` function returns the list of files, so you can split the output with a **They are the TypeScript client in another language.** Every capability is the same: typed models with `allOf` flattened, enums, discriminated unions decoded by their discriminator, and one method per operation. -The SDKs also include auth, retries with `Retry-After` and jittered backoff, timeouts, idempotency keys, middleware, and pagination iterators. +The SDKs also include [auth](#authentication), retries with `Retry-After` and jittered backoff, timeouts, idempotency keys, middleware, and pagination iterators. They also include SSE streaming, multipart bodies, binary downloads, typed response-header envelopes, and server-URL helpers for templated servers. Configuration is the same too: [`serverUrl`](../commands/generate-client.md), [`dateType`](../commands/generate-client.md), [`pagination`](../configuration/reference/client.md#pagination-object), and [`codeSamples`](../configuration/reference/client.md) all apply. @@ -255,7 +257,7 @@ The SDKs differ only where the language gives no choice: | Auth credentials | string or provider function | string or callable | string or callable | provider function only (no union types) | | Reserved-word fields | not applicable | trailing `_` (`type_`), wire name kept | trailing `_`, wire name kept | trailing `_` (`Type_`), `json` tag kept | | File layout | `single` or `split` (`outputMode`) | one file | one file | one file | -| Namespacing | ES module (the file path) | module name from the output stem | namespace from the API title | `package client`, or `goPackage` | +| Namespacing | ES module (the file path) | module name from the output file name | namespace from the API title | `package client`, or `goPackage` | | Runtime location | embedded or package (`runtime`) | embedded | embedded | embedded | `argsStyle` applies only to TypeScript call sites. @@ -400,6 +402,7 @@ Strip-only mode rejects these constructs, because it would have to generate assi Credentials are **per instance**. They live in the client config (`ClientConfig.auth`). Each operation automatically sends the credentials that its `security` requires. +A description that declares no `securitySchemes` produces a client with no auth code at all. The generator emits a setter for each `securityScheme` that the runtime can apply: | Scheme | Setter | Applied as | @@ -719,7 +722,7 @@ const envelope = await client.listCustomers({ params: { limit: 1 } }, { envelope - The TanStack Query and SWR wrappers do not accept `envelope`. Their options exclude it, and the wrappers strip it from the forwarded call. Because of this, cached data is always the plain body. - Call the sdk function directly when you need headers. + Call the client's operation function directly when you need headers. - The Python, PHP, and Go SDKs expose the same information as separate variants: `_with_headers()`, `WithHeaders()`, and `WithHeaders`. The generator emits these variants only for operations that declare success-response headers. Those languages cannot change a return type with a flag. @@ -908,14 +911,14 @@ The `tanstack-query` generator emits typed TanStack Query v5 factories for each The generator compiles the `initialPageParam`/`getNextPageParam` pair from the same [pagination](#pagination) rule that powers `.pages()`/`.items()`, and it includes the `hasMore` stop. Because of this, infinite queries need no hand-written `getNextPageParam`. `link`-style operations are the exception, because their next page lives in a response header that a `queryFn` cannot see. - Use the sdk's `.pages()`/`.items()` iterators for those. + Use the client's `.pages()`/`.items()` iterators for those. - `QueryKey(vars?)`. With `vars`, it returns the exact key that the options use. **Without arguments, it returns the invalidation prefix** that matches every cached page and filter of the operation: `queryClient.invalidateQueries({ queryKey: listOrdersQueryKey() })`. - `Mutation(init?)` for each mutation. Per-call `RequestOptions` (headers, a retry override) reach the mutation's requests. -The module-level factories bind the sdk's default `client`. +The module-level factories bind the generated module's default `client`. For an isolated instance with its own credentials, middleware, and retry, build a bound set with `createQueryFactories`: ```ts diff --git a/packages/cli/src/__tests__/client-generator-telemetry.test.ts b/packages/cli/src/__tests__/client-generator-telemetry.test.ts index d76eb3db29..4acd5f9b6a 100644 --- a/packages/cli/src/__tests__/client-generator-telemetry.test.ts +++ b/packages/cli/src/__tests__/client-generator-telemetry.test.ts @@ -83,10 +83,10 @@ describe('collectGeneratorUsage', () => { 'utf-8' ); // Two apis, the same entries — the cwd is elsewhere, only configDir resolves them. - collectGeneratorUsage(['sdk', './generators/php.mjs'], ['Printer'], configDir); - collectGeneratorUsage(['sdk', './generators/php.mjs'], ['Printer'], configDir); + collectGeneratorUsage(['typescript', './generators/php.mjs'], ['Printer'], configDir); + collectGeneratorUsage(['typescript', './generators/php.mjs'], ['Printer'], configDir); expect(generateClientTelemetry).toEqual({ - generate_client_builtin_generators: ['sdk'], + generate_client_builtin_generators: ['typescript'], generate_client_custom_generators_count: 1, generate_client_toolkit_imports: ['Printer'], generate_client_ejected_generators: ['php@0.3.0'], diff --git a/packages/cli/src/__tests__/commands/eject-generator.test.ts b/packages/cli/src/__tests__/commands/eject-generator.test.ts index a915df8d03..3359ece358 100644 --- a/packages/cli/src/__tests__/commands/eject-generator.test.ts +++ b/packages/cli/src/__tests__/commands/eject-generator.test.ts @@ -37,16 +37,16 @@ describe('wireConfig', () => { client: generators: - php - - sdk + - typescript `) ).toBe(outdent` client: generators: - ./generators/php.mjs - - sdk + - typescript `); - expect(wire('client:\n generators: [php, sdk]\n')).toBe( - 'client:\n generators: [./generators/php.mjs, sdk]\n' + expect(wire('client:\n generators: [php, typescript]\n')).toBe( + 'client:\n generators: [./generators/php.mjs, typescript]\n' ); }); @@ -55,15 +55,58 @@ describe('wireConfig', () => { wire(outdent` client: generators: - - sdk + - typescript `) ).toBe(outdent` client: generators: - - sdk + - typescript - ./generators/php.mjs `); }); + + it('inserts the generators list when the client block has none', () => { + expect( + wire(outdent` + client: + binName: cafe + apis: + cafe: + root: ./openapi.yaml + `) + ).toBe(outdent` + client: + generators: + - ./generators/php.mjs + binName: cafe + apis: + cafe: + root: ./openapi.yaml + `); + }); + + it('appends a client block when the config has none', () => { + expect( + wire( + outdent` + apis: + cafe: + root: ./openapi.yaml + clientOutput: ./src/client.ts + ` + '\n' + ) + ).toBe( + outdent` + apis: + cafe: + root: ./openapi.yaml + clientOutput: ./src/client.ts + client: + generators: + - ./generators/php.mjs + ` + '\n' + ); + }); }); describe('threeWayMerge', () => { diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts index 5527f6ee9b..de5eb42fbd 100644 --- a/packages/cli/src/commands/eject-generator.ts +++ b/packages/cli/src/commands/eject-generator.ts @@ -31,7 +31,7 @@ export const EJECTABLE = new Set([ 'python', 'go', 'php', - 'sdk', + 'typescript', 'zod', 'mock', 'swr', @@ -299,25 +299,44 @@ function wireDependency( * Add the ejected file to `client.generators` in the configuration file, editing the text * so comments and formatting survive. A bare `` entry is replaced rather than kept — * leaving both would make the next run fail on a name collision, since the ejected file - * declares the name it takes over. Only the two shapes we can extend without guessing - * are handled — a block sequence and a flow sequence under a top-level `client:` — and - * anything else returns false, so the caller prints the snippet instead of reshaping - * someone's config. + * declares the name it takes over. A config without a `client:` block or a `generators:` + * list gets the missing keys appended — the common shape, since `typescript` is the + * default and nobody lists it. Only a list we can extend without guessing is edited in + * place — a block sequence or a flow sequence — and anything else returns false, so the + * caller prints the snippet instead of reshaping someone's config. */ export function wireConfig(configPath: string | undefined, name: string, entry: string): boolean { if (configPath === undefined || !existsSync(configPath)) return false; const source = readFileSync(configPath, 'utf-8'); + if (source.includes(entry)) return true; const lines = source.split('\n'); const clientLine = lines.findIndex((line) => /^client:\s*$/.test(line)); - if (clientLine === -1) return false; - const generatorsLine = lines.findIndex( + if (clientLine === -1) { + if (/^client:/m.test(source)) return false; // `client: {...}` or similar — not a shape we edit + const separator = source === '' || source.endsWith('\n') ? '' : '\n'; + writeFileSync( + configPath, + `${source}${separator}client:\n generators:\n - ${entry}\n`, + 'utf-8' + ); + return true; + } + let generatorsLine = lines.findIndex( (line, index) => index > clientLine && /^\s+generators:/.test(line) ); - if (generatorsLine === -1) return false; - // Between `client:` and `generators:` there must be nothing dedented — otherwise the - // `generators:` we found belongs to another block. - if (lines.slice(clientLine + 1, generatorsLine).some((line) => /^\S/.test(line))) return false; - if (source.includes(entry)) return true; + // A `generators:` beyond a dedented line belongs to another block — the `client:` + // block itself has none. + if ( + generatorsLine !== -1 && + lines.slice(clientLine + 1, generatorsLine).some((line) => /^\S/.test(line)) + ) { + generatorsLine = -1; + } + if (generatorsLine === -1) { + lines.splice(clientLine + 1, 0, ' generators:', ` - ${entry}`); + writeFileSync(configPath, lines.join('\n'), 'utf-8'); + return true; + } const isNameEntry = (item: string) => item === name || item === `'${name}'` || item === `"${name}"`; @@ -511,8 +530,8 @@ export const handleEjectGenerator = async ({ ? `It also imports ${CORE_PACKAGE} (a dependency of the toolkit) — add it explicitly if your package manager does not hoist.\n` : '') + (wired - ? `Added it to client.generators in ${relative(process.cwd(), config.configPath!)} — the path entry takes over the built-in name.\n` - : `Point your config at the file — the path entry takes over the built-in name:\n\n` + + ? `Added it to client.generators in ${relative(process.cwd(), config.configPath!)} — the path to your copy replaces the built-in name.\n` + : `Point your config at the file — the path to your copy replaces the built-in name:\n\n` + ` client:\n generators:\n - ${configEntry}\n\n`) + `Your agent's skills: ${designSkill} (this generator's design) and ${authoringSkill} (the toolkit).\n` ); diff --git a/packages/cli/src/commands/generate-client.ts b/packages/cli/src/commands/generate-client.ts index 0c3d9a1029..b56989a710 100644 --- a/packages/cli/src/commands/generate-client.ts +++ b/packages/cli/src/commands/generate-client.ts @@ -153,7 +153,7 @@ export async function handleGenerateClient({ } if (seenOutputs.has(outputPath)) { throw new HandledError( - `\n❌ Two APIs resolve to the same output path: ${outputPath}.\n Give each api a distinct \`clientOutput\`.\n` + `\n❌ Two APIs write to the same path: ${outputPath}.\n Give each api a distinct \`clientOutput\`.\n` ); } seenOutputs.add(outputPath); @@ -173,7 +173,7 @@ export async function handleGenerateClient({ configDir, }); // The emitted module decides what composes: `cli` reaches a run as a built-in - // name, an ejected path entry, or another generator's prerequisite. + // name, a path to an ejected copy, or another generator's prerequisite. const cliModule = result.files.find((file) => file.path.endsWith('.cli.ts')); if (cliModule !== undefined) { const importExt = clientConfig.importExt ?? 'js'; @@ -182,6 +182,11 @@ export async function handleGenerateClient({ cliPath: cliModule.path.replace(/\.ts$/, importExt === 'ts' ? '.ts' : '.js'), }); } + // Sibling modules (`.cli.ts`, `.zod.ts`, …) count too: the composed entry is + // written after this loop and must not land on any of them. + for (const file of result.files) { + seenOutputs.add(file.path); + } const fileCount = `${result.files.length} ${pluralize('file', result.files.length)}`; const summary = `Client successfully generated: ${fileCount} (${ result.bytes @@ -204,6 +209,16 @@ export async function handleGenerateClient({ if (topLevelClient.cliOutput !== undefined && argv.api === undefined && composable.length > 0) { const { renderComposedCliEntry } = await import('@redocly/client-generator/generate'); const entryPath = resolvePath(configDir, topLevelClient.cliOutput); + if (!entryPath.endsWith('.ts')) { + throw new HandledError( + `\n❌ client.cliOutput must point at a TypeScript file (ending in .ts).\n Got: ${entryPath}\n` + ); + } + if (seenOutputs.has(entryPath)) { + throw new HandledError( + `\n❌ client.cliOutput resolves to a file this run generated: ${entryPath}.\n Give the composed entry its own path.\n` + ); + } const binName = topLevelClient.binName ?? basename(entryPath, extname(entryPath)) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a66a0333c7..aade26d929 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -956,7 +956,7 @@ yargs(hideBin(process.argv)) }, generator: { describe: - 'Generator to run; repeat the flag to run several (default: sdk). Built-in: sdk, zod, tanstack-query, tanstack-query-vue, tanstack-query-svelte, tanstack-query-solid, swr, mock, transformers, cli, cli-docs, python, go, php — or a path/package specifier for a custom generator. What each one emits is in the "Use the generated client" guide. Example: --generator sdk --generator zod', + 'Generator to run; repeat the flag to run several (default: typescript). Built-in: typescript, zod, tanstack-query, tanstack-query-vue, tanstack-query-svelte, tanstack-query-solid, swr, mock, transformers, cli, cli-docs, python, go, php — or a path/package specifier for a custom generator. What each one emits is in the "Use the generated client" guide. Example: --generator typescript --generator zod', type: 'string', array: true, requiresArg: true, diff --git a/packages/cli/src/utils/client-generator-telemetry.ts b/packages/cli/src/utils/client-generator-telemetry.ts index 4e29c45508..b98fa51323 100644 --- a/packages/cli/src/utils/client-generator-telemetry.ts +++ b/packages/cli/src/utils/client-generator-telemetry.ts @@ -19,7 +19,7 @@ export const generateClientTelemetry: GenerateClientTelemetry = {}; /** Allowlist for the builtin-usage event — anything not here is counted, never named. */ export const BUILTIN_GENERATOR_NAMES = new Set([ - 'sdk', + 'typescript', 'zod', 'tanstack-query', 'tanstack-query-vue', diff --git a/packages/client-generator/ARCHITECTURE.md b/packages/client-generator/ARCHITECTURE.md index 4702a382af..89fa30d8f6 100644 --- a/packages/client-generator/ARCHITECTURE.md +++ b/packages/client-generator/ARCHITECTURE.md @@ -65,7 +65,7 @@ builder and the emitters ([ADR-0003](./docs/adr/0003-spec-agnostic-ir.md)). Places where behavior varies without editing in place: - **The `getGenerator` seam** — a generator is `(input) => GeneratedFile[]` (`generators/types.ts`). - `generateClient` resolves the configured selection (default `['sdk']`) via `resolveGenerators` (`generators/resolve.ts`) into a name→descriptor registry, then runs them through `collectGeneratedFiles` and merges their files (duplicate output paths throw). + `generateClient` resolves the configured selection (default `['typescript']`) via `resolveGenerators` (`generators/resolve.ts`) into a name→descriptor registry, then runs them through `collectGeneratedFiles` and merges their files (duplicate output paths throw). A selection entry is a built-in name, the `name` of an inline `customGenerators` entry, or a **plugin import specifier** (path or package, dynamically imported and validated). This is the public, **experimental** extension point — authored with `defineGenerator` from `@redocly/client-generator`, which also re-exports the IR types and the codegen toolkit. Where new capabilities (zod, framework hooks) plug in. @@ -104,7 +104,7 @@ Three orthogonal knobs combine freely: Plus **error mode** (`--error-mode`: `throw` · `result`), **date type** (`--date-type`: `string` · `Date`), and the `--server-url` / `--setup` modifiers. Every client exports **both call styles** — the instance and the free functions; args style only shapes the free-function sugar. -Orthogonally, **`--generator`** selects which generators run (default `sdk`; plus `zod`, `tanstack-query` (React; `-vue`/`-svelte`/`-solid` variants), `swr`, `transformers`, `mock`, and custom plugins), with per-generator knobs: `--mock-data` (`static` · `faker`) / `--mock-seed` (for `mock`). +Orthogonally, **`--generator`** selects which generators run (default `typescript`; plus `zod`, `tanstack-query` (React; `-vue`/`-svelte`/`-solid` variants), `swr`, `transformers`, `mock`, and custom plugins), with per-generator knobs: `--mock-data` (`static` · `faker`) / `--mock-seed` (for `mock`). ## Test architeture diff --git a/packages/client-generator/CONTEXT.md b/packages/client-generator/CONTEXT.md index 2d8c3c4641..daf97b1f14 100644 --- a/packages/client-generator/CONTEXT.md +++ b/packages/client-generator/CONTEXT.md @@ -51,18 +51,18 @@ _Avoid_: renderer, codegen. Chooses the _file layout_ from the IR and emit options, then fills each file by calling the emitter. Lives in `writers/`. One **Writer** per **output mode**, selected by `getWriter`. -A Writer is an implementation detail of the `sdk` **Generator**. +A Writer is an implementation detail of the `typescript` **Generator**. _Avoid_: formatter, builder. **Generator**: A deep module that turns the IR into a set of files for one concern, selected by name through the registry seam. Each one lives in its OWN FOLDER under `generators/` — `index.ts` plus an `AGENTS.md` design skill that the code must match (change the skill first). The `python`, `go`, and `php` generators are self-contained single files, which is what makes them ejectable; the TypeScript-emitting ones are thin entries over the shared emitters. -The `sdk` generator is the typed client (it delegates to the output-mode **Writer**). +The `typescript` generator is the typed client (it delegates to the output-mode **Writer**). The `zod` generator emits a standalone `.zod.ts` **schema module** (one `export const Schema` per IR named schema) beside the client. -The `tanstack-query` generator emits a TanStack Query v5 (React) module (`.tanstack.ts`) wrapping the sdk — per query op a `QueryKey`/`Options` (`queryOptions`) factory + query key, per mutation a `Mutation` (`mutationKey`/`mutationFn`) factory (requires the `sdk` generator; the consumer installs `@tanstack/react-query`). +The `tanstack-query` generator emits a TanStack Query v5 (React) module (`.tanstack.ts`) wrapping the sdk — per query op a `QueryKey`/`Options` (`queryOptions`) factory + query key, per mutation a `Mutation` (`mutationKey`/`mutationFn`) factory (requires the `typescript` generator; the consumer installs `@tanstack/react-query`). The `transformers` generator emits a standalone `.transformers.ts` of `transform(data: ): ` functions — one per IR named schema that (recursively) carries a `date-time`/`date` field — that walk the value and rewrite wire ISO strings to `new Date(...)` in place, composing across refs (`transformPet` calls `transformOwner`); pair it with the **dateType** knob (`--date-type Date`) so the parsed value matches the type (it imports only the schema TYPES, so the client stays zero-dep). -`generateClient` runs the configured generators (default `['sdk']`, selected via `--generator sdk --generator zod`) and merges their files. +`generateClient` runs the configured generators (default `['typescript']`, selected via `--generator typescript --generator zod`) and merges their files. Custom generators are authored with `defineGenerator` and selected inline or by import specifier (the experimental **plugin** API, ADR-0012). _Avoid_: middleware (that's a runtime concept). @@ -127,7 +127,7 @@ _Avoid_: throwOnError, errorHandling, result shape (in code identifiers). **dateType**: How `format: date-time`/`date` string fields are typed: `string` (default — byte-identical to the ISO wire shape) or `Date`. Selected by `--date-type`. -Under `Date` the sdk emits `Date` for those scalar `string` schemas; the runtime conversion is opt-in and separate — pair it with the **`transformers` generator** (`--generator sdk --generator transformers`) so the parsed value matches the type. +Under `Date` the sdk emits `Date` for those scalar `string` schemas; the runtime conversion is opt-in and separate — pair it with the **`transformers` generator** (`--generator typescript --generator transformers`) so the parsed value matches the type. `int64` → `bigint` is deferred to a follow-up. _Avoid_: dateMode, parseDates (in code identifiers). diff --git a/packages/client-generator/README.md b/packages/client-generator/README.md index 937ef3f76b..d9d3e11ae0 100644 --- a/packages/client-generator/README.md +++ b/packages/client-generator/README.md @@ -27,7 +27,7 @@ import { generateClient } from '@redocly/client-generator'; const result = await generateClient({ api: './openapi.yaml', // file path or URL; OpenAPI 3.0/3.1/3.2 or Swagger 2.0 output: './src/api/client.ts', - generators: ['sdk', 'zod'], + generators: ['typescript', 'zod'], }); console.log(`Wrote ${result.files.length} file(s), ${result.bytes} bytes.`); @@ -64,7 +64,7 @@ import { tsType } from '@redocly/client-generator/generate'; export default defineGenerator({ name: 'response-map', - requires: ['sdk'], + requires: ['typescript'], run({ model, outputPath }) { const printer = new Printer(); // One `ResponseShapes` entry per operation with a JSON success body. diff --git a/packages/client-generator/eject-assets/AGENTS.md b/packages/client-generator/eject-assets/AGENTS.md index 4eef87fd28..370c6e0027 100644 --- a/packages/client-generator/eject-assets/AGENTS.md +++ b/packages/client-generator/eject-assets/AGENTS.md @@ -6,7 +6,7 @@ in the same pass as the built-ins; select it by path in `redocly.yaml`: ```yaml client: - generators: [sdk, ./generators/my-generator.mjs] + generators: [typescript, ./generators/my-generator.mjs] ``` ## The contract @@ -51,7 +51,7 @@ Users set them per generator name: ```yaml client: - generators: [sdk, ./generators/permissions-matrix.mjs] + generators: [typescript, ./generators/permissions-matrix.mjs] options: permissions-matrix: groupBy: path diff --git a/packages/client-generator/eject-assets/skills/cli-docs-generator/SKILL.md b/packages/client-generator/eject-assets/skills/cli-docs-generator/SKILL.md index fef20f8bcc..16bf19ca8a 100644 --- a/packages/client-generator/eject-assets/skills/cli-docs-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/cli-docs-generator/SKILL.md @@ -23,7 +23,7 @@ per command with its positionals and flags (type, required, choices, description second model drifts from the tool the first time either side changes, so it never does that. A new CLI capability shows up here only when it is in the command table. - **Requires the `cli` generator** it documents: selecting `cli-docs` pulls in `cli` (and - through it `sdk` and `zod`), so `--generator cli-docs` is a complete, consistent set. + through it `typescript` and `zod`), so `--generator cli-docs` is a complete, consistent set. - **The renderer IS the template.** Publishers who need another structure eject this generator rather than learning a template syntax — one customization mechanism, no template engine, no new dependency. Light customization stays in declared options. 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 b1ca8fe7dd..2f7e99ebe9 100644 --- a/packages/client-generator/eject-assets/skills/cli-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/cli-generator/SKILL.md @@ -39,7 +39,7 @@ with `--help`, a `schema ` introspection command, and `--dry-run`. prints the prepared request with credentials REDACTED. Help lists only the credentials the description declares, and an unusable `--token` is a usage error, never silently dropped. -- **Validation is on by default.** The generator declares `requires: ['sdk', 'zod']` and +- **Validation is on by default.** The generator declares `requires: ['typescript', 'zod']` and the pipeline pulls prerequisites in automatically, so `--generator cli` alone produces a validating CLI — a user shouldn't have to know which other generator provides it. The consequence is a zod peer dependency at run time, which the docs state. 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 aa5372fecd..deab5cb79c 100644 --- a/packages/client-generator/eject-assets/skills/client-generators/SKILL.md +++ b/packages/client-generator/eject-assets/skills/client-generators/SKILL.md @@ -11,7 +11,7 @@ in the same pass as the built-ins; select it by path in `redocly.yaml`: ```yaml client: - generators: [sdk, ./generators/my-generator.mjs] + generators: [typescript, ./generators/my-generator.mjs] ``` ## The contract @@ -56,7 +56,7 @@ Users set them per generator name: ```yaml client: - generators: [sdk, ./generators/permissions-matrix.mjs] + generators: [typescript, ./generators/permissions-matrix.mjs] options: permissions-matrix: groupBy: path 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 62ca0f1e99..e0fc15fef2 100644 --- a/packages/client-generator/eject-assets/skills/swr-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/swr-generator/SKILL.md @@ -16,7 +16,7 @@ React SWR hooks over the sdk's exported operation functions: `use()` with a ## Design decisions that must hold -- **Wraps the sdk's functions** — it never re-implements requests, so it requires `sdk` +- **Wraps the sdk's functions** — it never re-implements requests, so it requires `typescript` and is throw-mode only. - **Keys are exported factories** so consumers can invalidate precisely. - **`envelope` is excluded** from hook options (`Omit`) and 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 5c2c6bbfc4..96f9cd4e80 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 @@ -24,7 +24,7 @@ query keys. One generator, four framework variants (`react` default, `-vue`, - **Infinite queries** derive `getNextPageParam` from the resolved pagination rule; a `link`-style rule reads the `Link` header the descriptor declares. - **`envelope` is excluded and stripped** — cached data is the plain body. -- Requires `sdk`; throw-mode only (it wraps thrown errors into query errors). +- Requires `typescript`; throw-mode only (it wraps thrown errors into query errors). ## Emitters that implement it 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 947911d405..62c09908c7 100644 --- a/packages/client-generator/eject-assets/skills/transformers-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/transformers-generator/SKILL.md @@ -19,7 +19,7 @@ values and back — the bridge for `dateType: Date` clients. - **Requires `dateType: Date`** (declared as `dateTypes: ['Date']`, so a mismatched selection fails fast): the converters assign `Date` objects to fields the sdk types as `Date`, which only type-checks in that mode. -- **Imports the sdk's schema TYPES** (so `sdk` is required) and nothing else. +- **Imports the sdk's schema TYPES** (so `typescript` is required) and nothing else. - Converters are pure and total: every named schema gets a pair, nested structures recurse, and a missing optional stays missing. diff --git a/packages/client-generator/eject-assets/skills/sdk-generator/SKILL.md b/packages/client-generator/eject-assets/skills/typescript-generator/SKILL.md similarity index 81% rename from packages/client-generator/eject-assets/skills/sdk-generator/SKILL.md rename to packages/client-generator/eject-assets/skills/typescript-generator/SKILL.md index af31b929b8..9e105fc41d 100644 --- a/packages/client-generator/eject-assets/skills/sdk-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/typescript-generator/SKILL.md @@ -1,13 +1,13 @@ --- -name: sdk-generator -description: Design of the ejected Redocly `sdk` client generator. Read it, and update it, before changing generators/sdk.mjs. +name: typescript-generator +description: Design of the ejected Redocly `typescript` client generator. Read it, and update it, before changing generators/typescript.mjs. --- -# The `sdk` generator — its skill +# The `typescript` generator — its skill -This file is the DESIGN of your ejected `sdk` generator (`generators/sdk.mjs`): +This file is the DESIGN of your ejected `typescript` generator (`generators/typescript.mjs`): **to change the generator, edit this skill first, then make the code match it** — a diff -to `generators/sdk.mjs` that has no covering sentence here is incomplete. +to `generators/typescript.mjs` that has no covering sentence here is incomplete. ## What it emits @@ -44,7 +44,7 @@ sugar), `descriptor.ts`, `ts-type.ts`/`ts-literal.ts` (type + data text), `sse.t ## Ejecting it -`redocly eject-generator sdk` ships this generator BUNDLED with the emitters it uses — +`redocly eject-generator typescript` ships this generator BUNDLED with the emitters it uses — one `.mjs` you own, unminified, with a comment marking each source module. It imports only `@redocly/client-generator` (the toolkit and the embedded runtime) and `@redocly/openapi-core` (`logger`, `isPlainObject`), so runtime fixes still arrive by @@ -58,8 +58,8 @@ generation time. ## The modify loop 1. Edit this skill: state the new behavior or decision. -2. Make `generators/sdk.mjs` match it. +2. Make `generators/typescript.mjs` match it. 3. Run `redocly generate-client` and inspect the `git diff` of the generated output — generated files are never hand-edited. -Newer built-in versions merge in with `redocly eject-generator sdk --update`. +Newer built-in versions merge in with `redocly eject-generator typescript --update`. diff --git a/packages/client-generator/scripts/generate-eject-assets.mjs b/packages/client-generator/scripts/generate-eject-assets.mjs index e8cafb201e..dd255fb355 100644 --- a/packages/client-generator/scripts/generate-eject-assets.mjs +++ b/packages/client-generator/scripts/generate-eject-assets.mjs @@ -153,7 +153,12 @@ const LANGUAGE = [ * ejected copy is the place to change it rather than four near-identical files. */ const TYPESCRIPT = [ - { name: 'sdk', imports: ['sdkGenerator', 'sdkSample'], run: 'sdkGenerator', sample: 'sdkSample' }, + { + name: 'typescript', + imports: ['typescriptGenerator', 'typescriptSample'], + run: 'typescriptGenerator', + sample: 'typescriptSample', + }, { name: 'zod', imports: ['zodGenerator'], run: 'zodGenerator' }, { name: 'mock', imports: ['mockGenerator'], run: 'mockGenerator' }, { name: 'swr', imports: ['swrGenerator'], run: 'swrGenerator' }, diff --git a/packages/client-generator/src/__tests__/index.test.ts b/packages/client-generator/src/__tests__/index.test.ts index 6048377179..7b51a6ae71 100644 --- a/packages/client-generator/src/__tests__/index.test.ts +++ b/packages/client-generator/src/__tests__/index.test.ts @@ -90,7 +90,7 @@ describe('collectGeneratedFiles', () => { outputPath: '/out/api.ts', outputMode: 'single', emit: {}, - generators: ['sdk'], + generators: ['typescript'], }); expect(files.length).toBe(1); expect(files[0].path).toBe('/out/api.ts'); @@ -102,7 +102,7 @@ describe('collectGeneratedFiles', () => { outputPath: '/out/api.ts', outputMode: 'single', emit: {}, - generators: ['sdk', 'sdk'], + generators: ['typescript', 'typescript'], }) ).toThrow(/already emitted/); }); @@ -173,7 +173,7 @@ describe('collectGeneratedFiles', () => { outputPath: '/out/api.ts', outputMode: 'split', emit: { runtime: 'package' }, - generators: ['sdk'], + generators: ['typescript'], }); // No schemas in the model → only the entry file. expect(files.map((f) => f.path)).toEqual(['/out/api.ts']); 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 d151d2b290..160eba8de4 100644 --- a/packages/client-generator/src/__tests__/pipeline-ts-free.test.ts +++ b/packages/client-generator/src/__tests__/pipeline-ts-free.test.ts @@ -59,9 +59,9 @@ describe('pipeline (lib/pipeline.js)', () => { }); }); -describe('the sdk generator itself (lib/generators/sdk/index.js)', () => { +describe('the sdk generator itself (lib/generators/typescript/index.js)', () => { it('loads no typescript — the whole emit path is text templates (setup baking stays lazy)', () => { - const { externals } = staticGraph(join(libDir, 'generators/sdk/index.js')); + const { externals } = staticGraph(join(libDir, 'generators/typescript/index.js')); expect(externals.has('typescript')).toBe(false); }); }); diff --git a/packages/client-generator/src/__tests__/plugin.test.ts b/packages/client-generator/src/__tests__/plugin.test.ts index b16f146ee4..f436b11390 100644 --- a/packages/client-generator/src/__tests__/plugin.test.ts +++ b/packages/client-generator/src/__tests__/plugin.test.ts @@ -3,7 +3,7 @@ import { type CustomGenerator, defineGenerator } from '../plugin.js'; describe('plugin entry', () => { it('defineGenerator returns its argument unchanged', () => { - const gen: CustomGenerator = { name: 'route-map', requires: ['sdk'], run: () => [] }; + const gen: CustomGenerator = { name: 'route-map', requires: ['typescript'], run: () => [] }; expect(defineGenerator(gen)).toBe(gen); }); diff --git a/packages/client-generator/src/emitters/cli-docs.ts b/packages/client-generator/src/emitters/cli-docs.ts index a47897e400..9b1ca81be7 100644 --- a/packages/client-generator/src/emitters/cli-docs.ts +++ b/packages/client-generator/src/emitters/cli-docs.ts @@ -129,13 +129,16 @@ export function renderCliDocs(commands: CliCommand[], options: CliDocsOptions): printer.blank(); printer.line('| Flag | Description |'); printer.line('| ---- | ----------- |'); + // `--token` mirrors the CLI itself: without a bearer scheme the tool rejects the flag, + // so the reference must not list it. + const hasBearer = options.schemes.some((scheme) => scheme.kind === 'bearer'); for (const [flag, description] of [ ['--server-url ', 'Override the server URL included in the client.'], ['--format ', 'Output format.'], ['--dry-run', 'Print the prepared request, credentials redacted, without sending it.'], ['--page-all', 'Follow pagination, printing one JSON page per line.'], ['--output ', 'Write the response body to a file. Required for binary responses.'], - ['--token ', 'Bearer token, overriding the environment.'], + ...(hasBearer ? [['--token ', 'Bearer token, overriding the environment.']] : []), ['--json ', 'Request body, inline or from a file or stdin.'], ] as const) { printer.line(`| \`${flag}\` | ${description} |`); diff --git a/packages/client-generator/src/emitters/tanstack-query.ts b/packages/client-generator/src/emitters/tanstack-query.ts index 836e3bbaf2..ee7afdabd9 100644 --- a/packages/client-generator/src/emitters/tanstack-query.ts +++ b/packages/client-generator/src/emitters/tanstack-query.ts @@ -127,7 +127,7 @@ function factoriesSource( '/**\n' + ' * Build the factories over a specific client instance — its config, middleware, and\n' + ' * retry apply to every call (`createQueryFactories(createClient(OPERATIONS, config))`).\n' + - " * The module-level exports below are these factories bound to the sdk's default `client`.\n" + + " * The module-level exports below are these factories bound to the generated module's default `client`.\n" + ' */\n' + 'export const createQueryFactories = (instance: typeof client = client) => ({\n' + members.join(',\n') + diff --git a/packages/client-generator/src/generators/__tests__/cli-docs.test.ts b/packages/client-generator/src/generators/__tests__/cli-docs.test.ts index c312c490fc..1ee20754dc 100644 --- a/packages/client-generator/src/generators/__tests__/cli-docs.test.ts +++ b/packages/client-generator/src/generators/__tests__/cli-docs.test.ts @@ -64,7 +64,7 @@ function render(options: Record = {}): string { outputPath: '/out/cafe.client.ts', outputMode: 'single', emit: {}, - selected: ['sdk', 'zod', 'cli', 'cli-docs'], + selected: ['typescript', 'zod', 'cli', 'cli-docs'], options, }); expect(files).toHaveLength(1); @@ -102,6 +102,23 @@ describe('cliDocsGenerator', () => { expect(page).toContain('validation error'); }); + it('lists --token only when the description declares a bearer scheme, like the CLI itself', () => { + expect(render()).toContain('--token'); + const noBearer = modelWith([operation({ name: 'ping', method: 'get', path: '/ping' })], { + title: 'Cafe API', + securitySchemes: [{ kind: 'apiKeyHeader', key: 'ApiKeyAuth', headerName: 'X-Api-Key' }], + }); + const files = cliDocsGenerator({ + model: noBearer, + outputPath: '/out/cafe.client.ts', + outputMode: 'single', + emit: {}, + selected: ['typescript', 'zod', 'cli', 'cli-docs'], + options: {}, + }); + expect(files[0].content).not.toContain('--token'); + }); + it('says when a body is one the CLI cannot build, instead of implying the command runs', () => { const page = render(); expect(page).toContain('### `coffee-orders uploadPhoto`'); diff --git a/packages/client-generator/src/generators/__tests__/cli.test.ts b/packages/client-generator/src/generators/__tests__/cli.test.ts index 10b22ae0db..68190093e7 100644 --- a/packages/client-generator/src/generators/__tests__/cli.test.ts +++ b/packages/client-generator/src/generators/__tests__/cli.test.ts @@ -51,7 +51,7 @@ describe('cliGenerator', () => { outputPath: '/out/client.ts', outputMode: 'single', emit: {}, - selected: ['sdk', 'cli'], + selected: ['typescript', 'cli'], }); expect(files).toHaveLength(1); expect(files[0].path).toBe('/out/client.cli.ts'); @@ -62,7 +62,7 @@ describe('cliGenerator', () => { outputPath: '/out/client.ts', outputMode: 'single', emit: {}, - selected: ['sdk', 'zod', 'cli'], + selected: ['typescript', 'zod', 'cli'], }); // Request validation always; response validation off for a dry run, whose response is // the dry-run stub rather than the server's. @@ -72,21 +72,38 @@ describe('cliGenerator', () => { }); it('declares its prerequisites and rejects result mode', () => { - // `sdk` + `zod` are pulled in by the resolver (see resolve.test.ts); validation + // `typescript` + `zod` are pulled in by the resolver (see resolve.test.ts); validation // still refuses a selection whose prerequisites are genuinely absent. - expect(builtinGenerators().get('cli')?.requires).toEqual(['sdk', 'zod']); - expect(() => validateGenerators(['cli'], {})).toThrow(/requires the "sdk" generator/); - expect(() => validateGenerators(['sdk', 'zod', 'cli'], { errorMode: 'result' })).toThrow( + expect(builtinGenerators().get('cli')?.requires).toEqual(['typescript', 'zod']); + expect(() => validateGenerators(['cli'], {})).toThrow(/requires the "typescript" generator/); + expect(() => validateGenerators(['typescript', 'zod', 'cli'], { errorMode: 'result' })).toThrow( /does not support --error-mode "result"/ ); - expect(() => validateGenerators(['sdk', 'zod', 'cli'], {})).not.toThrow(); + expect(() => validateGenerators(['typescript', 'zod', 'cli'], {})).not.toThrow(); }); - it('renders a shell x-codeSamples snippet per operation', () => { + it('renders a shell x-codeSamples snippet per operation, addressed by the group slug', () => { const op = MODEL.services[0].operations[0]; const sample = cliSample(op, { model: MODEL, emit: {} }); expect(sample).toMatchObject({ lang: 'shell', label: 'CLI' }); - expect(sample?.source).toContain('Orders getOrder '); + // The CLI dispatches on the slugged group, so the sample must use it — the raw + // tag ("Orders", or worse a multi-word one) would not resolve. + expect(sample?.source).toContain('orders getOrder '); + expect(sample?.source).not.toContain('Orders getOrder'); + }); + + it('slugs a multi-word tag into the group the CLI accepts', () => { + const model = { + ...MODEL, + services: [ + { + name: 'Orders', + operations: [{ ...MODEL.services[0].operations[0], tags: ['Coffee Orders'] }], + }, + ], + } as ApiModel; + const sample = cliSample(model.services[0].operations[0], { model, emit: {} }); + expect(sample?.source).toContain('coffee-orders getOrder '); }); }); diff --git a/packages/client-generator/src/generators/__tests__/fixtures/route-map-plugin.ts b/packages/client-generator/src/generators/__tests__/fixtures/route-map-plugin.ts index 1591f760c7..a5009cff29 100644 --- a/packages/client-generator/src/generators/__tests__/fixtures/route-map-plugin.ts +++ b/packages/client-generator/src/generators/__tests__/fixtures/route-map-plugin.ts @@ -3,7 +3,7 @@ import type { CustomGenerator } from '../../types.js'; const generator: CustomGenerator = { name: 'route-map', - requires: ['sdk'], + requires: ['typescript'], run({ model, outputPath }) { const routes = model.services .flatMap((s) => s.operations) diff --git a/packages/client-generator/src/generators/__tests__/generator-skills.test.ts b/packages/client-generator/src/generators/__tests__/generator-skills.test.ts index f0c9187836..f8caa8f38a 100644 --- a/packages/client-generator/src/generators/__tests__/generator-skills.test.ts +++ b/packages/client-generator/src/generators/__tests__/generator-skills.test.ts @@ -15,7 +15,7 @@ const generatorsDir = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const LANGUAGE = ['python', 'go', 'php']; /** TypeScript generators: thin entries over shared emitters, ejected bundled with them. */ const TYPESCRIPT = [ - 'sdk', + 'typescript', 'zod', 'mock', 'cli', diff --git a/packages/client-generator/src/generators/__tests__/index.test.ts b/packages/client-generator/src/generators/__tests__/index.test.ts index 4e88714d31..c7266da974 100644 --- a/packages/client-generator/src/generators/__tests__/index.test.ts +++ b/packages/client-generator/src/generators/__tests__/index.test.ts @@ -2,12 +2,12 @@ import { logger } from '@redocly/openapi-core'; import { NotSupportedError } from '../../errors.js'; import { builtinGenerators, validateGenerators } from '../index.js'; -import { sdkGenerator } from '../sdk/index.js'; +import { typescriptGenerator } from '../typescript/index.js'; import { zodGenerator } from '../zod/index.js'; describe('builtinGenerators', () => { it('registers the sdk generator descriptor', () => { - expect(builtinGenerators().get('sdk')?.run).toBe(sdkGenerator); + expect(builtinGenerators().get('typescript')?.run).toBe(typescriptGenerator); }); it('registers the zod generator descriptor', () => { @@ -21,7 +21,7 @@ describe('builtinGenerators', () => { describe('validateGenerators', () => { it('accepts sdk alone', () => { - expect(() => validateGenerators(['sdk'], {})).not.toThrow(); + expect(() => validateGenerators(['typescript'], {})).not.toThrow(); }); it('accepts zod alone — it requires nothing', () => { @@ -29,30 +29,34 @@ describe('validateGenerators', () => { }); it('accepts sdk + tanstack-query with the default error-mode', () => { - expect(() => validateGenerators(['sdk', 'tanstack-query'], {})).not.toThrow(); + expect(() => validateGenerators(['typescript', 'tanstack-query'], {})).not.toThrow(); }); it.each(['tanstack-query', 'transformers', 'swr', 'mock'] as const)( - 'rejects %s without sdk, naming the fix', + 'rejects %s without typescript, naming the fix', (generator) => { expect(() => validateGenerators([generator], {})).toThrow( - new RegExp(`requires the "sdk" generator.*--generator sdk --generator ${generator}`) + new RegExp( + `requires the "typescript" generator.*--generator typescript --generator ${generator}` + ) ); } ); it('rejects transformers without --date-type Date (would assign Date to string fields)', () => { - expect(() => validateGenerators(['sdk', 'transformers'], {})).toThrow( + expect(() => validateGenerators(['typescript', 'transformers'], {})).toThrow( /requires --date-type Date .*got "string"/ ); }); it('accepts sdk + transformers with --date-type Date', () => { - expect(() => validateGenerators(['sdk', 'transformers'], { dateType: 'Date' })).not.toThrow(); + expect(() => + validateGenerators(['typescript', 'transformers'], { dateType: 'Date' }) + ).not.toThrow(); }); it.each(['tanstack-query', 'swr'] as const)('rejects %s with result error mode', (generator) => { - expect(() => validateGenerators(['sdk', generator], { errorMode: 'result' })).toThrow( + expect(() => validateGenerators(['typescript', generator], { errorMode: 'result' })).toThrow( /does not support --error-mode "result".*throw/ ); }); @@ -86,7 +90,12 @@ describe('validateGenerators', () => { // The TypeScript sdk applies all of them — no warning. warn.mockClear(); - validateGenerators(['sdk'], { runtime: 'package', argsStyle: 'grouped' }, undefined, 'split'); + validateGenerators( + ['typescript'], + { runtime: 'package', argsStyle: 'grouped' }, + undefined, + 'split' + ); expect(warn).not.toHaveBeenCalled(); } finally { warn.mockRestore(); @@ -105,7 +114,7 @@ describe('validateGenerators', () => { // The generator that reads it is selected, so nothing to say — even alongside // generators that don't read it. warn.mockClear(); - validateGenerators(['sdk', 'zod', 'cli'], { binName: 'cafe-api' }); + validateGenerators(['typescript', 'zod', 'cli'], { binName: 'cafe-api' }); validateGenerators(['go'], { goPackage: 'mypkg' }); expect(warn).not.toHaveBeenCalled(); } finally { @@ -119,14 +128,14 @@ describe('validateGenerators', () => { }); describe('swr generator', () => { - it('is registered and requires sdk', () => { + it('is registered and requires typescript', () => { const descriptor = builtinGenerators().get('swr'); expect(descriptor?.run).toBeDefined(); - expect(descriptor?.requires).toContain('sdk'); + expect(descriptor?.requires).toContain('typescript'); }); it('accepts sdk + swr with the default error-mode', () => { - expect(() => validateGenerators(['sdk', 'swr'], {})).not.toThrow(); + expect(() => validateGenerators(['typescript', 'swr'], {})).not.toThrow(); }); }); @@ -154,7 +163,7 @@ describe('validateGenerators — runtime compatibility', () => { it('accepts the wrapper generators with runtime: package (no longer restricted)', () => { expect(() => validateGenerators( - ['sdk', 'tanstack-query', 'swr'], + ['typescript', 'tanstack-query', 'swr'], { runtime: 'package' }, builtinGenerators() ) @@ -163,11 +172,11 @@ describe('validateGenerators — runtime compatibility', () => { }); describe('mock generator', () => { - it('is registered and requires sdk', () => { - expect(builtinGenerators().get('mock')?.requires).toContain('sdk'); + it('is registered and requires typescript', () => { + expect(builtinGenerators().get('mock')?.requires).toContain('typescript'); }); it('validateGenerators accepts sdk + mock', () => { - expect(() => validateGenerators(['sdk', 'mock'], {})).not.toThrow(); + expect(() => validateGenerators(['typescript', 'mock'], {})).not.toThrow(); }); }); diff --git a/packages/client-generator/src/generators/__tests__/resolve.test.ts b/packages/client-generator/src/generators/__tests__/resolve.test.ts index 837e7db57f..bc037552fd 100644 --- a/packages/client-generator/src/generators/__tests__/resolve.test.ts +++ b/packages/client-generator/src/generators/__tests__/resolve.test.ts @@ -11,9 +11,9 @@ const noopRun = () => []; describe('resolveGenerators', () => { it('passes built-in names through unchanged', async () => { - const { selected, registry } = await resolveGenerators(['sdk', 'zod']); - expect(selected).toEqual(['sdk', 'zod']); - expect(registry.has('sdk')).toBe(true); + const { selected, registry } = await resolveGenerators(['typescript', 'zod']); + expect(selected).toEqual(['typescript', 'zod']); + expect(registry.has('typescript')).toBe(true); expect(registry.has('zod')).toBe(true); }); @@ -29,10 +29,10 @@ describe('resolveGenerators', () => { it('registers an inline custom generator and selects it by name', async () => { const custom: CustomGenerator = { name: 'route-map', run: noopRun }; - const { selected, registry } = await resolveGenerators(['sdk', 'route-map'], { + const { selected, registry } = await resolveGenerators(['typescript', 'route-map'], { customGenerators: [custom], }); - expect(selected).toEqual(['sdk', 'route-map']); + expect(selected).toEqual(['typescript', 'route-map']); expect(registry.get('route-map')?.run).toBe(noopRun); }); @@ -40,13 +40,13 @@ describe('resolveGenerators', () => { // `--generator cli` alone should produce a working, validating CLI. const { selected } = await resolveGenerators(['cli']); expect(selected).toContain('cli'); - expect(selected).toContain('sdk'); + expect(selected).toContain('typescript'); expect(selected).toContain('zod'); // A prerequisite runs BEFORE the generator that needs it. - expect(selected.indexOf('sdk')).toBeLessThan(selected.indexOf('cli')); + expect(selected.indexOf('typescript')).toBeLessThan(selected.indexOf('cli')); // An explicit selection is not duplicated or reordered away. - const explicit = await resolveGenerators(['sdk', 'zod', 'cli']); - expect(explicit.selected).toEqual(['sdk', 'zod', 'cli']); + const explicit = await resolveGenerators(['typescript', 'zod', 'cli']); + expect(explicit.selected).toEqual(['typescript', 'zod', 'cli']); }); it('accepts a generator whose requiresGenerator range covers the running version', async () => { @@ -92,8 +92,10 @@ describe('resolveGenerators', () => { it('registers an inline custom that is available (for requires) but not selected', async () => { const custom: CustomGenerator = { name: 'extra', run: noopRun }; - const { selected, registry } = await resolveGenerators(['sdk'], { customGenerators: [custom] }); - expect(selected).toEqual(['sdk']); + const { selected, registry } = await resolveGenerators(['typescript'], { + customGenerators: [custom], + }); + expect(selected).toEqual(['typescript']); expect(registry.has('extra')).toBe(true); }); @@ -123,10 +125,13 @@ describe('resolveGenerators', () => { }); it('loads a generator from a relative path specifier and selects its declared name', async () => { - const { selected, registry } = await resolveGenerators(['sdk', './route-map-plugin.ts'], { - configDir: fixtures, - }); - expect(selected).toEqual(['sdk', 'route-map']); + const { selected, registry } = await resolveGenerators( + ['typescript', './route-map-plugin.ts'], + { + configDir: fixtures, + } + ); + expect(selected).toEqual(['typescript', 'route-map']); expect(registry.has('route-map')).toBe(true); }); @@ -136,7 +141,7 @@ describe('resolveGenerators', () => { const { selected } = await resolveGenerators(['./route-map-plugin.ts'], { configDir: fixtures, }); - expect(selected).toEqual(['sdk', 'route-map']); + expect(selected).toEqual(['typescript', 'route-map']); }); it('rejects URL specifiers — remote generator modules are not supported', async () => { diff --git a/packages/client-generator/src/generators/__tests__/tanstack-query.test.ts b/packages/client-generator/src/generators/__tests__/tanstack-query.test.ts index b83cb089ed..7e188b6e2a 100644 --- a/packages/client-generator/src/generators/__tests__/tanstack-query.test.ts +++ b/packages/client-generator/src/generators/__tests__/tanstack-query.test.ts @@ -77,7 +77,7 @@ describe('tanstackQueryGenerator', () => { 'tanstack-query-solid', ]) { const descriptor = registry.get(name); - expect(descriptor?.requires, name).toEqual(['sdk']); + expect(descriptor?.requires, name).toEqual(['typescript']); expect(descriptor?.errorModes, name).toEqual(['throw']); } }); diff --git a/packages/client-generator/src/generators/__tests__/sdk.test.ts b/packages/client-generator/src/generators/__tests__/typescript.test.ts similarity index 92% rename from packages/client-generator/src/generators/__tests__/sdk.test.ts rename to packages/client-generator/src/generators/__tests__/typescript.test.ts index 169e3b29ac..db6fb923e0 100644 --- a/packages/client-generator/src/generators/__tests__/sdk.test.ts +++ b/packages/client-generator/src/generators/__tests__/typescript.test.ts @@ -1,6 +1,6 @@ import { HEADER } from '../../emitters/emit-options.js'; import type { ApiModel } from '../../intermediate-representation/model.js'; -import { sdkGenerator } from '../sdk/index.js'; +import { typescriptGenerator } from '../typescript/index.js'; function apiModel(): ApiModel { return { @@ -32,9 +32,9 @@ function apiModel(): ApiModel { }; } -describe('sdkGenerator', () => { +describe('typescriptGenerator', () => { it('writes the whole client to the output path in single mode', () => { - const files = sdkGenerator({ + const files = typescriptGenerator({ model: apiModel(), outputPath: '/out/api.ts', outputMode: 'single', @@ -48,7 +48,7 @@ describe('sdkGenerator', () => { it('honors the output mode (split carves the schemas into a sibling file)', () => { const model = apiModel(); model.schemas = [{ name: 'Thing', schema: { kind: 'object', properties: [] } }]; - const files = sdkGenerator({ + const files = typescriptGenerator({ model, outputPath: '/out/api.ts', outputMode: 'split', @@ -69,7 +69,7 @@ describe('sdkGenerator', () => { model.services[0].operations[0].successResponses = [ { contentType: 'application/json', schema: { kind: 'ref', name: 'Pet' }, status: 200 }, ]; - const files = sdkGenerator({ + const files = typescriptGenerator({ model, outputPath: '/out/api.ts', outputMode: 'split', @@ -84,7 +84,7 @@ describe('sdkGenerator', () => { it('emits .ts import extensions when importExt is ts (Node native TS execution)', () => { const model = apiModel(); model.schemas = [{ name: 'Thing', schema: { kind: 'object', properties: [] } }]; - const files = sdkGenerator({ + const files = typescriptGenerator({ model, outputPath: '/out/api.ts', outputMode: 'split', diff --git a/packages/client-generator/src/generators/cli-docs/AGENTS.md b/packages/client-generator/src/generators/cli-docs/AGENTS.md index 9b2afdc797..1bfd4945e4 100644 --- a/packages/client-generator/src/generators/cli-docs/AGENTS.md +++ b/packages/client-generator/src/generators/cli-docs/AGENTS.md @@ -17,7 +17,7 @@ per command with its positionals and flags (type, required, choices, description second model drifts from the tool the first time either side changes, so it never does that. A new CLI capability shows up here only when it is in the command table. - **Requires the `cli` generator** it documents: selecting `cli-docs` pulls in `cli` (and - through it `sdk` and `zod`), so `--generator cli-docs` is a complete, consistent set. + through it `typescript` and `zod`), so `--generator cli-docs` is a complete, consistent set. - **The renderer IS the template.** Publishers who need another structure eject this generator rather than learning a template syntax — one customization mechanism, no template engine, no new dependency. Light customization stays in declared options. diff --git a/packages/client-generator/src/generators/cli/AGENTS.md b/packages/client-generator/src/generators/cli/AGENTS.md index 68fd760226..18a37ef3d8 100644 --- a/packages/client-generator/src/generators/cli/AGENTS.md +++ b/packages/client-generator/src/generators/cli/AGENTS.md @@ -33,7 +33,7 @@ with `--help`, a `schema ` introspection command, and `--dry-run`. prints the prepared request with credentials REDACTED. Help lists only the credentials the description declares, and an unusable `--token` is a usage error, never silently dropped. -- **Validation is on by default.** The generator declares `requires: ['sdk', 'zod']` and +- **Validation is on by default.** The generator declares `requires: ['typescript', 'zod']` and the pipeline pulls prerequisites in automatically, so `--generator cli` alone produces a validating CLI — a user shouldn't have to know which other generator provides it. The consequence is a zod peer dependency at run time, which the docs state. diff --git a/packages/client-generator/src/generators/cli/index.ts b/packages/client-generator/src/generators/cli/index.ts index 6fe9105b15..8d2c914cdc 100644 --- a/packages/client-generator/src/generators/cli/index.ts +++ b/packages/client-generator/src/generators/cli/index.ts @@ -2,14 +2,15 @@ import { join } from 'node:path'; import { commandData, renderCliModule } from '../../emitters/cli.js'; import type { OperationModel } from '../../intermediate-representation/model.js'; +import { groupSlug } from '../../runtime/cli.js'; import { anchor } from '../anchor.js'; import type { CodeSample, Generator, SampleContext } from '../types.js'; /** * The cli generator: a bin-ready `.cli.ts` — a zero-dependency, typed - * command-line interface over the sibling sdk client (typed flags, `--json` + * command-line interface over the sibling client (typed flags, `--json` * bodies, env auth, `--page-all`, SSE/blob output, a documented exit-code - * contract). Requires `sdk` (throw mode); wires zod validation when co-selected. + * contract). Requires `typescript` (throw mode); wires zod validation when co-selected. */ /** The stem as a command name: dots and other non-word characters fold to `-`. */ function commandName(stem: string): string { @@ -42,7 +43,7 @@ export function cliSample(op: OperationModel, ctx: SampleContext): CodeSample | if (command === undefined) return undefined; const words = [ 'client', - ...(command.group ? [command.group] : []), + ...(command.group ? [groupSlug(command.group)] : []), command.name, ...command.positionals.map((positional) => `<${positional.name}>`), ...command.flags.filter((flag) => flag.required).map((flag) => `--${flag.name} <${flag.type}>`), diff --git a/packages/client-generator/src/generators/index.ts b/packages/client-generator/src/generators/index.ts index 2f380cf297..1aaa39e7fa 100644 --- a/packages/client-generator/src/generators/index.ts +++ b/packages/client-generator/src/generators/index.ts @@ -6,11 +6,11 @@ import { BUILTIN_META, validateSelection, type BuiltinMeta } from './meta.js'; import { mockGenerator } from './mock/index.js'; import { phpGenerator, phpSample } from './php/index.js'; import { pythonGenerator, pythonSample } from './python/index.js'; -import { sdkGenerator, sdkSample } from './sdk/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 { typescriptGenerator, typescriptSample } from './typescript/index.js'; import { zodGenerator } from './zod/index.js'; export type { @@ -26,7 +26,7 @@ export type { // only the eagerly imported `run` functions live here. The pipeline entry never // touches this module: it loads built-ins lazily through the meta table. const RUNS: Record> = { - sdk: { run: sdkGenerator, sample: sdkSample }, + typescript: { run: typescriptGenerator, sample: typescriptSample }, zod: { run: zodGenerator }, transformers: { run: transformersGenerator }, 'tanstack-query': { run: tanstackQueryGenerator('react') }, diff --git a/packages/client-generator/src/generators/meta.ts b/packages/client-generator/src/generators/meta.ts index d81c483c8d..5c07b9385a 100644 --- a/packages/client-generator/src/generators/meta.ts +++ b/packages/client-generator/src/generators/meta.ts @@ -15,7 +15,7 @@ export type BuiltinMeta = Omit & { function tanstackQuery(framework: 'react' | 'vue' | 'svelte' | 'solid'): BuiltinMeta { return { - requires: ['sdk'], + requires: ['typescript'], errorModes: ['throw'], load: () => import('./tanstack-query/index.js').then((m) => ({ @@ -37,43 +37,47 @@ const LANGUAGE_SDK_NOT_APPLICABLE: BuiltinMeta['notApplicable'] = { }; export const BUILTIN_META: Record = { - // sdk is the base client; zod emits a standalone schema module importing nothing from it. - sdk: { + // typescript is the base client; zod emits a standalone schema module importing nothing from it. + typescript: { load: () => - import('./sdk/index.js').then((m) => ({ run: m.sdkGenerator, sample: m.sdkSample })), + import('./typescript/index.js').then((m) => ({ + run: m.typescriptGenerator, + sample: m.typescriptSample, + })), }, zod: { load: () => import('./zod/index.js').then((m) => ({ run: m.zodGenerator })) }, - // transformers import the schema *types* from the sdk entry module (so sdk must run) and - // assign `Date` values to those fields, which only type-checks when the sdk types dates as `Date`. + // transformers import the schema *types* from the client entry module (so typescript must + // run) and assign `Date` values to those fields, which only type-checks when the client + // types dates as `Date`. transformers: { - requires: ['sdk'], + requires: ['typescript'], dateTypes: ['Date'], load: () => import('./transformers/index.js').then((m) => ({ run: m.transformersGenerator })), }, - // tanstack-query wraps the sdk's exported, throw-mode operation functions — present in + // tanstack-query wraps the client's exported, throw-mode operation functions — present in // both runtime distributions, so no runtime restriction. The framework variants differ // only in the `@tanstack/-query` import; the bare name means React. 'tanstack-query': tanstackQuery('react'), 'tanstack-query-vue': tanstackQuery('vue'), 'tanstack-query-svelte': tanstackQuery('svelte'), 'tanstack-query-solid': tanstackQuery('solid'), - // swr wraps the sdk's exported, throw-mode operation functions as SWR hooks. + // swr wraps the client's exported, throw-mode operation functions as SWR hooks. swr: { - requires: ['sdk'], + requires: ['typescript'], errorModes: ['throw'], load: () => import('./swr/index.js').then((m) => ({ run: m.swrGenerator })), }, - // mock emits a standalone MSW handlers/factories module referencing the sdk's types. + // mock emits a standalone MSW handlers/factories module referencing the client's types. mock: { - requires: ['sdk'], + requires: ['typescript'], load: () => import('./mock/index.js').then((m) => ({ run: m.mockGenerator })), }, - // cli dispatches through the sdk's instance client and relies on thrown ApiError - // for its exit-code mapping, so it is sdk-bound and throw-only. + // cli dispatches through the generated instance client and relies on thrown ApiError + // for its exit-code mapping, so it is bound to `typescript` and throw-only. // Validation is part of the CLI's contract (exit code 3), so it requires `zod` — // the pipeline pulls prerequisites in, so `--generator cli` alone is enough. cli: { - requires: ['sdk', 'zod'], + requires: ['typescript', 'zod'], errorModes: ['throw'], load: () => import('./cli/index.js').then((m) => ({ run: m.cliGenerator, sample: m.cliSample })), @@ -144,8 +148,8 @@ export function validateSelection( ): void { const selected = new Set(names); // Options only one generator reads. `notApplicable` can't express this: it fires per - // generator, so marking `binName` on `sdk` would warn on `--generator sdk --generator - // cli`, where `cli` does apply it. Setting one with none of its generators selected + // generator, so marking `binName` on `typescript` would warn on `--generator typescript + // --generator cli`, where `cli` does apply it. Setting one with none of its generators selected // does nothing at all, which is worth saying. for (const { option, generators, reason } of SINGLE_GENERATOR_OPTIONS) { if (emit[option] !== undefined && !generators.some((generator) => selected.has(generator))) { diff --git a/packages/client-generator/src/generators/swr/AGENTS.md b/packages/client-generator/src/generators/swr/AGENTS.md index 355366ed16..bcf82cd6e4 100644 --- a/packages/client-generator/src/generators/swr/AGENTS.md +++ b/packages/client-generator/src/generators/swr/AGENTS.md @@ -10,7 +10,7 @@ React SWR hooks over the sdk's exported operation functions: `use()` with a ## Design decisions that must hold -- **Wraps the sdk's functions** — it never re-implements requests, so it requires `sdk` +- **Wraps the sdk's functions** — it never re-implements requests, so it requires `typescript` and is throw-mode only. - **Keys are exported factories** so consumers can invalidate precisely. - **`envelope` is excluded** from hook options (`Omit`) and diff --git a/packages/client-generator/src/generators/swr/index.ts b/packages/client-generator/src/generators/swr/index.ts index 0cc86c6fbc..977bb3d938 100644 --- a/packages/client-generator/src/generators/swr/index.ts +++ b/packages/client-generator/src/generators/swr/index.ts @@ -10,7 +10,7 @@ import type { Generator } from '../types.js'; * sdk operation functions — `Key` + `use` (`useSWR`) per query (GET/HEAD), * `use` (`useSWRMutation`) per mutation. It imports the operation functions + * their `Variables` types from the sdk entry (`./.js`), so it requires the - * `sdk` generator and targets its throw-mode operation functions. `swr`/`swr/mutation` + * `typescript` generator and targets its throw-mode operation functions. `swr`/`swr/mutation` * are the consumer's peer; the sdk client stays dependency-free. * * Output-mode-agnostic: `./.js` resolves to the single-file client or the diff --git a/packages/client-generator/src/generators/tanstack-query/AGENTS.md b/packages/client-generator/src/generators/tanstack-query/AGENTS.md index 6455b7913b..9ef8d59129 100644 --- a/packages/client-generator/src/generators/tanstack-query/AGENTS.md +++ b/packages/client-generator/src/generators/tanstack-query/AGENTS.md @@ -18,7 +18,7 @@ query keys. One generator, four framework variants (`react` default, `-vue`, - **Infinite queries** derive `getNextPageParam` from the resolved pagination rule; a `link`-style rule reads the `Link` header the descriptor declares. - **`envelope` is excluded and stripped** — cached data is the plain body. -- Requires `sdk`; throw-mode only (it wraps thrown errors into query errors). +- Requires `typescript`; throw-mode only (it wraps thrown errors into query errors). ## Emitters that implement it diff --git a/packages/client-generator/src/generators/tanstack-query/index.ts b/packages/client-generator/src/generators/tanstack-query/index.ts index 8e718bdef5..973cf35014 100644 --- a/packages/client-generator/src/generators/tanstack-query/index.ts +++ b/packages/client-generator/src/generators/tanstack-query/index.ts @@ -12,7 +12,7 @@ import type { Generator } from '../types.js'; * mutation, all built by `createQueryFactories(c)` (bindable to any client instance) * with the module-level exports bound to the sdk's default `client`. It imports the * `client` instance + the `Variables` types from the sdk entry (`./.js`), so - * it requires the `sdk` generator and its throw-mode client. The option helpers are + * it requires the `typescript` generator and its throw-mode client. The option helpers are * imported from `@tanstack/-query` (the consumer's peer); the registry binds * one framework per generator name, and the emitted body is byte-identical across them. * diff --git a/packages/client-generator/src/generators/transformers/AGENTS.md b/packages/client-generator/src/generators/transformers/AGENTS.md index ba0a643890..68faf3bdc8 100644 --- a/packages/client-generator/src/generators/transformers/AGENTS.md +++ b/packages/client-generator/src/generators/transformers/AGENTS.md @@ -13,7 +13,7 @@ values and back — the bridge for `dateType: Date` clients. - **Requires `dateType: Date`** (declared as `dateTypes: ['Date']`, so a mismatched selection fails fast): the converters assign `Date` objects to fields the sdk types as `Date`, which only type-checks in that mode. -- **Imports the sdk's schema TYPES** (so `sdk` is required) and nothing else. +- **Imports the sdk's schema TYPES** (so `typescript` is required) and nothing else. - Converters are pure and total: every named schema gets a pair, nested structures recurse, and a missing optional stays missing. diff --git a/packages/client-generator/src/generators/types.ts b/packages/client-generator/src/generators/types.ts index 14c13ed1dd..d0cd2295c1 100644 --- a/packages/client-generator/src/generators/types.ts +++ b/packages/client-generator/src/generators/types.ts @@ -18,7 +18,7 @@ export type GeneratedFile = { path: string; content: string }; /** The first-party generators the registry knows. */ export type GeneratorName = - | 'sdk' + | 'typescript' | 'zod' | 'tanstack-query' | 'tanstack-query-vue' @@ -91,10 +91,10 @@ export type SampleContext = { model: ApiModel; emit: EmitOptions }; * fast with an actionable message instead of producing a client that won't compile. * * - `requires`: other generators that must also be selected (e.g. `tanstack-query` - * imports the sdk's operation functions, so it requires `sdk`). + * imports the client's operation functions, so it requires `typescript`). * - `errorModes` / `dateTypes` / `runtimes`: the subset this generator supports; * `undefined` means "all". (`tanstack-query` wraps throw-mode functions, so it - * supports only `throw` mode; `transformers` only type-checks when the sdk types + * supports only `throw` mode; `transformers` only type-checks when the client types * date fields as `Date`, so it supports only `dateType: 'Date'`.) */ export type GeneratorDescriptor = { diff --git a/packages/client-generator/src/generators/sdk/AGENTS.md b/packages/client-generator/src/generators/typescript/AGENTS.md similarity index 95% rename from packages/client-generator/src/generators/sdk/AGENTS.md rename to packages/client-generator/src/generators/typescript/AGENTS.md index 0a5b462743..c29743fe98 100644 --- a/packages/client-generator/src/generators/sdk/AGENTS.md +++ b/packages/client-generator/src/generators/typescript/AGENTS.md @@ -1,4 +1,4 @@ -# The `sdk` generator — its skill +# The `typescript` generator — its skill This file is the generator's DESIGN and governs our own changes: **to change the generator, edit this skill first, then make the code match it** — a diff with no @@ -39,7 +39,7 @@ sugar), `descriptor.ts`, `ts-type.ts`/`ts-literal.ts` (type + data text), `sse.t ## Ejecting it -`redocly eject-generator sdk` ships this generator BUNDLED with the emitters it uses — +`redocly eject-generator typescript` ships this generator BUNDLED with the emitters it uses — one `.mjs` you own, unminified, with a comment marking each source module. It imports only `@redocly/client-generator` (the toolkit and the embedded runtime) and `@redocly/openapi-core` (`logger`, `isPlainObject`), so runtime fixes still arrive by diff --git a/packages/client-generator/src/generators/sdk/index.ts b/packages/client-generator/src/generators/typescript/index.ts similarity index 92% rename from packages/client-generator/src/generators/sdk/index.ts rename to packages/client-generator/src/generators/typescript/index.ts index f9ff8554e6..cd142397b3 100644 --- a/packages/client-generator/src/generators/sdk/index.ts +++ b/packages/client-generator/src/generators/typescript/index.ts @@ -15,7 +15,7 @@ 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 sdkGenerator: Generator = ({ model, outputPath, outputMode, emit }) => { +export const typescriptGenerator: Generator = ({ model, outputPath, outputMode, emit }) => { if (outputMode === 'split') { const { dir, stem } = anchor(outputPath); const { entry, schemas } = emitClientSplit(model, emit, stem); @@ -30,7 +30,7 @@ export const sdkGenerator: Generator = ({ model, outputPath, outputMode, emit }) }; /** One idiomatic TS call per operation — the `x-codeSamples` reference implementation. */ -export function sdkSample(op: OperationModel, ctx: SampleContext): CodeSample { +export function typescriptSample(op: OperationModel, ctx: SampleContext): CodeSample { const ident = packageIdents(ctx.model).get(op.name) ?? op.name; const requiredQuery = op.queryParams.filter((param) => param.required); const slots: string[] = []; diff --git a/packages/client-generator/src/pipeline.ts b/packages/client-generator/src/pipeline.ts index f22e032517..d29e79dd31 100644 --- a/packages/client-generator/src/pipeline.ts +++ b/packages/client-generator/src/pipeline.ts @@ -173,9 +173,9 @@ export async function generateClient( // Resolve the selection into a registry: built-in names load lazily, inline // `customGenerators` register, and any other entry is imported as a plugin // specifier (path/package). An empty list (e.g. `generators: []` in config, or - // no `--generator` flags) means "unspecified" — fall back to the default sdk - // client rather than emitting nothing. - const requested = options.generators?.length ? options.generators : ['sdk']; + // no `--generator` flags) means "unspecified" — fall back to the default + // typescript client rather than emitting nothing. + const requested = options.generators?.length ? options.generators : ['typescript']; const { selected, registry } = await resolveGenerators(requested, { customGenerators: options.customGenerators, configDir: options.configDir, diff --git a/packages/client-generator/src/plugin.ts b/packages/client-generator/src/plugin.ts index 7641e33878..9b45e84a27 100644 --- a/packages/client-generator/src/plugin.ts +++ b/packages/client-generator/src/plugin.ts @@ -7,7 +7,7 @@ // A custom generator is `(GeneratorInput) => GeneratedFile[]` plus a `name`; select it in // `generators` by name (inline via `customGenerators`) or by import specifier (path/package). It // receives the same spec-agnostic IR (`model`) the built-in generators consume, and may use the same -// TypeScript-emitting toolkit re-exported below, so a plugin is a first-class peer of `sdk`/`zod`/… +// TypeScript-emitting toolkit re-exported below, so a plugin is a first-class peer of `typescript`/`zod`/… // The generated client stays dependency-free: a plugin's output is its own file(s), and its runtime // libraries are peers of the consumer's app, never of the client. // @@ -17,7 +17,7 @@ // // import { tsType } from '@redocly/client-generator/generate'; // export default defineGenerator({ // name: 'route-map', -// requires: ['sdk'], +// requires: ['typescript'], // run({ model, outputPath }) { // const routes = model.services.flatMap((s) => s.operations) // .map((op) => ` ${op.name}: '${op.method.toUpperCase()} ${op.path}',`).join('\n'); diff --git a/packages/client-generator/src/types.ts b/packages/client-generator/src/types.ts index f10a4a55cf..75da79716e 100644 --- a/packages/client-generator/src/types.ts +++ b/packages/client-generator/src/types.ts @@ -59,8 +59,8 @@ export type GenerateClientOptions = { * generated APIs share one QueryClient (operationIds may collide across APIs). */ queryKeyPrefix?: string; /** - * Generators to run, in order. Defaults to `['sdk']`. Each entry is a built-in name - * (`sdk`/`zod`/`tanstack-query`/`swr`/`transformers`/`mock`), the `name` of an inline + * Generators to run, in order. Defaults to `['typescript']`. Each entry is a built-in name + * (`typescript`/`zod`/`tanstack-query`/`swr`/`transformers`/`mock`), the `name` of an inline * `customGenerators` entry, or an import specifier (a path or package) for a custom generator. */ generators?: string[]; diff --git a/tests/e2e/generate-client/cli-compose.test.ts b/tests/e2e/generate-client/cli-compose.test.ts index c6a55f556a..dc1d85e28d 100644 --- a/tests/e2e/generate-client/cli-compose.test.ts +++ b/tests/e2e/generate-client/cli-compose.test.ts @@ -149,7 +149,7 @@ describe('config-driven composition (client.cliOutput)', () => { // A directory nothing else creates — the composed entry makes its own. ' cliOutput: ./bin/cafe.ts', ' importExt: ts', - ' generators: [sdk, zod, cli]', + ' generators: [typescript, zod, cli]', 'apis:', ` shop: { root: ${fixture}, clientOutput: ./src/shop.ts }`, ` kitchen: { root: ${fixture}, clientOutput: ./src/kitchen.ts }`, @@ -166,7 +166,7 @@ describe('config-driven composition (client.cliOutput)', () => { ); expect(ejected.status, ejected.stderr).toBe(0); expect(readFileSync(join(project, 'redocly.yaml'), 'utf-8')).toContain( - 'generators: [sdk, zod, ./generators/cli.mjs]' + 'generators: [typescript, zod, ./generators/cli.mjs]' ); const generated = spawnSync( 'node', @@ -204,3 +204,42 @@ describe('config-driven composition (client.cliOutput)', () => { expect(JSON.stringify(captured)).not.toContain('k-secret'); }); }); + +describe('client.cliOutput validation', () => { + const generateWith = (cliOutput: string) => { + const project = mkdtempSync(join(tmpdir(), 'cli-output-invalid-')); + const fixture = join(__dirname, 'fixtures/cli.yaml'); + writeFileSync( + join(project, 'redocly.yaml'), + [ + 'extends: []', + 'client:', + ` cliOutput: ${cliOutput}`, + ' generators: [typescript, zod, cli]', + 'apis:', + ` shop: { root: ${fixture}, clientOutput: ./src/shop.ts }`, + '', + ].join('\n'), + 'utf-8' + ); + const result = spawnSync( + 'node', + [cliEntry, 'generate-client', '--config', join(project, 'redocly.yaml')], + { cwd: project, encoding: 'utf-8' } + ); + rmSync(project, { recursive: true, force: true }); + return result; + }; + + it('rejects a non-.ts entry instead of writing TypeScript into it', () => { + const result = generateWith('./bin/cafe.js'); + expect(result.status).toBe(1); + expect(result.stderr).toContain('client.cliOutput must point at a TypeScript file'); + }); + + it('rejects an entry that lands on a file the run generated', () => { + const result = generateWith('./src/shop.cli.ts'); + expect(result.status).toBe(1); + expect(result.stderr).toContain('client.cliOutput resolves to a file this run generated'); + }); +}); diff --git a/tests/e2e/generate-client/cli.test.ts b/tests/e2e/generate-client/cli.test.ts index 0c8f37d6d4..a9929b50d3 100644 --- a/tests/e2e/generate-client/cli.test.ts +++ b/tests/e2e/generate-client/cli.test.ts @@ -46,7 +46,7 @@ describe('generate-client cli generator (end-to-end)', () => { beforeAll(async () => { generate(fixture, join(clientDir, 'client.ts'), [ '--generator', - 'sdk', + 'typescript', '--generator', 'zod', '--generator', @@ -56,7 +56,7 @@ describe('generate-client cli generator (end-to-end)', () => { // A second copy with `.ts` specifiers: what a zero-build `node` runner needs. generate(fixture, join(stripDir, 'client.ts'), [ '--generator', - 'sdk', + 'typescript', '--generator', 'zod', '--generator', diff --git a/tests/e2e/generate-client/eject.test.ts b/tests/e2e/generate-client/eject.test.ts index cf02b009a5..ae1abfd2b7 100644 --- a/tests/e2e/generate-client/eject.test.ts +++ b/tests/e2e/generate-client/eject.test.ts @@ -78,7 +78,7 @@ describe('eject-generator (end-to-end)', () => { writeFileSync(join(wired, 'package.json'), JSON.stringify({ name: 'demo' }), 'utf-8'); writeFileSync( join(wired, 'redocly.yaml'), - 'extends: []\nclient:\n generators:\n - sdk\n', + 'extends: []\nclient:\n generators:\n - typescript\n', 'utf-8' ); const eject = run(wired, ['eject-generator', 'go']); @@ -91,7 +91,7 @@ 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 - sdk\n - ./generators/go.mjs\n' + 'extends: []\nclient:\n generators:\n - typescript\n - ./generators/go.mjs\n' ); // Re-ejecting must not add the entry twice. @@ -160,7 +160,7 @@ describe('eject-generator (end-to-end)', () => { '--output', 'zod-builtin/client.ts', '--generator', - 'sdk', + 'typescript', '--generator', 'zod', ]); @@ -171,7 +171,7 @@ describe('eject-generator (end-to-end)', () => { '--output', 'zod-ejected/client.ts', '--generator', - 'sdk', + 'typescript', '--generator', './generators/zod.mjs', ]); diff --git a/tests/e2e/generate-client/examples/README.md b/tests/e2e/generate-client/examples/README.md index bc48d74a85..ba11ab2347 100644 --- a/tests/e2e/generate-client/examples/README.md +++ b/tests/e2e/generate-client/examples/README.md @@ -6,31 +6,31 @@ Most share the cafe spec in [`_shared/cafe.yaml`](./_shared/cafe.yaml); the rest The generated client under `src/api/` is gitignored — CI regenerates every client and type-checks the consumer code against it (the `examples` job), and `zero-install-quickstart` keeps its client committed as the canonical browsable copy, drift-checked in `tests/e2e/generate-client/examples.test.ts`. -| Example | How it's generated | Shows | -| ---------------------------------------------------------- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | -| [fetch-functions](./fetch-functions) | CLI · `sdk`, functions | free functions + `ApiError` | -| [baked-setup](./baked-setup) | CLI · `sdk`, functions | publisher defaults baked into the client via `--setup` (`defineClientSetup`) | -| [zod](./zod) | CLI · `sdk`, `zod` | validating responses with generated zod schemas | -| [tanstack-query](./tanstack-query) | CLI · `sdk`, `tanstack-query` | React `useQuery(Options())` | -| [mock](./mock) | CLI · `sdk`, `mock` | MSW handlers from generated `handlers` | -| [programmatic](./programmatic) | `generateClient(...)` API | generating the client from a Node script | -| [package-runtime](./package-runtime) | CLI · `sdk`, package runtime | `runtime: package` — types + descriptors only; the versioned runtime is imported from `@redocly/client-generator`, fixes via `npm update` | -| [zero-install-quickstart](./zero-install-quickstart) | CLI · `sdk` | the first-touch loop: generate → import → call; one self-contained file, zero runtime dependencies | -| [node-native](./node-native) | CLI · `sdk` | `importExt: ts` — `.ts` import specifiers so plain `node src/main.ts` runs the client via Node's built-in type stripping | -| [configure-and-middleware](./configure-and-middleware) | CLI · `sdk` | `configure({ serverUrl, retry, fetch })`, `use()` targeting `ctx.operation` (literal unions), body mutation, auth setter, `ApiError.body` | -| [multi-instance](./multi-instance) | CLI · `sdk`, package runtime | per-tenant instances via `createClient(OPERATIONS)` — works in both runtimes; this example uses `runtime: package` | -| [sse-streaming](./sse-streaming) | CLI · `sdk` | typed `for await` over SSE, auto-reconnect via `Last-Event-ID` (`reconnectDelay`/`reconnect: false`), clean abort | -| [vendored-edge](./vendored-edge) | CLI · `sdk` | the generated file copied into a no-npm edge worker (`export default { fetch }`); `typescript` is the only dev tool | -| [pagination](./pagination) | CLI · `sdk` | auto-pagination from a `client.pagination` convention: `for await` over `.items()`/`.pages()` next to the unchanged one-shot call | -| [custom-pagination](./custom-pagination) | CLI · `sdk` | hand-written paging over the typed client for shapes the built-in styles don't cover (body cursors) | -| [custom-generator](./custom-generator) | CLI · `sdk` + custom generator | a local `generators` plugin emitting a `: 'METHOD /path'` route map next to the sdk | -| [typescript-types-generator](./typescript-types-generator) | CLI · `sdk` + custom generator | a plugin rendering real TypeScript types via `@redocly/client-generator/generate` (`tsType`) — a typed response-shape map | -| [nested-facade](./nested-facade) | CLI · `sdk` + custom generator | `api..` facade derived from the spec's tags by a plugin — regenerates with the spec | -| [cli](./cli) | CLI · `sdk`, `zod`, `cli` | a bin-ready command-line interface over the client: typed flags, `--json` bodies, `--dry-run`, a documented exit-code contract | -| [python-sdk](./python-sdk) | CLI · `python` | a full Python SDK (httpx): typed dataclasses, sync/async clients, pagination iterators | -| [go-sdk](./go-sdk) | CLI · `go` | a full Go SDK (stdlib-only): typed structs, `(T, error)` methods, `context.Context` | -| [php-sdk](./php-sdk) | CLI · `php` | a full PHP SDK (curl extension): promoted-constructor classes, native enums, named-argument methods | -| [ejected-generator](./ejected-generator) | CLI · ejected `php` | `eject-generator php` vendored + customized: the path entry shadows the built-in name; regeneration keeps the customization | +| Example | How it's generated | Shows | +| ---------------------------------------------------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| [fetch-functions](./fetch-functions) | CLI · `typescript`, functions | free functions + `ApiError` | +| [baked-setup](./baked-setup) | CLI · `typescript`, functions | publisher defaults baked into the client via `--setup` (`defineClientSetup`) | +| [zod](./zod) | CLI · `typescript`, `zod` | validating responses with generated zod schemas | +| [tanstack-query](./tanstack-query) | CLI · `typescript`, `tanstack-query` | React `useQuery(Options())` | +| [mock](./mock) | CLI · `typescript`, `mock` | MSW handlers from generated `handlers` | +| [programmatic](./programmatic) | `generateClient(...)` API | generating the client from a Node script | +| [package-runtime](./package-runtime) | CLI · `typescript`, package runtime | `runtime: package` — types + descriptors only; the versioned runtime is imported from `@redocly/client-generator`, fixes via `npm update` | +| [zero-install-quickstart](./zero-install-quickstart) | CLI · `typescript` | the first-touch loop: generate → import → call; one self-contained file, zero runtime dependencies | +| [node-native](./node-native) | CLI · `typescript` | `importExt: ts` — `.ts` import specifiers so plain `node src/main.ts` runs the client via Node's built-in type stripping | +| [configure-and-middleware](./configure-and-middleware) | CLI · `typescript` | `configure({ serverUrl, retry, fetch })`, `use()` targeting `ctx.operation` (literal unions), body mutation, auth setter, `ApiError.body` | +| [multi-instance](./multi-instance) | CLI · `typescript`, package runtime | per-tenant instances via `createClient(OPERATIONS)` — works in both runtimes; this example uses `runtime: package` | +| [sse-streaming](./sse-streaming) | CLI · `typescript` | typed `for await` over SSE, auto-reconnect via `Last-Event-ID` (`reconnectDelay`/`reconnect: false`), clean abort | +| [vendored-edge](./vendored-edge) | CLI · `typescript` | the generated file copied into a no-npm edge worker (`export default { fetch }`); `typescript` is the only dev tool | +| [pagination](./pagination) | CLI · `typescript` | auto-pagination from a `client.pagination` convention: `for await` over `.items()`/`.pages()` next to the unchanged one-shot call | +| [custom-pagination](./custom-pagination) | CLI · `typescript` | hand-written paging over the typed client for shapes the built-in styles don't cover (body cursors) | +| [custom-generator](./custom-generator) | CLI · `typescript` + custom generator | a local `generators` plugin emitting a `: 'METHOD /path'` route map next to the client | +| [typescript-types-generator](./typescript-types-generator) | CLI · `typescript` + custom generator | a plugin rendering real TypeScript types via `@redocly/client-generator/generate` (`tsType`) — a typed response-shape map | +| [nested-facade](./nested-facade) | CLI · `typescript` + custom generator | `api..` facade derived from the spec's tags by a plugin — regenerates with the spec | +| [cli](./cli) | CLI · `typescript`, `zod`, `cli` | a bin-ready command-line interface over the client: typed flags, `--json` bodies, `--dry-run`, a documented exit-code contract | +| [python-sdk](./python-sdk) | CLI · `python` | a full Python SDK (httpx): typed dataclasses, sync/async clients, pagination iterators | +| [go-sdk](./go-sdk) | CLI · `go` | a full Go SDK (stdlib-only): typed structs, `(T, error)` methods, `context.Context` | +| [php-sdk](./php-sdk) | CLI · `php` | a full PHP SDK (curl extension): promoted-constructor classes, native enums, named-argument methods | +| [ejected-generator](./ejected-generator) | CLI · ejected `php` | `eject-generator php` vendored + customized: the path entry shadows the built-in name; regeneration keeps the customization | ## Run one diff --git a/tests/e2e/generate-client/examples/baked-setup/redocly.yaml b/tests/e2e/generate-client/examples/baked-setup/redocly.yaml index 91dd1bc792..d9e7777530 100644 --- a/tests/e2e/generate-client/examples/baked-setup/redocly.yaml +++ b/tests/e2e/generate-client/examples/baked-setup/redocly.yaml @@ -7,5 +7,5 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript setup: ./client-setup.ts diff --git a/tests/e2e/generate-client/examples/cli/redocly.yaml b/tests/e2e/generate-client/examples/cli/redocly.yaml index f18d6ee8db..f45a484037 100644 --- a/tests/e2e/generate-client/examples/cli/redocly.yaml +++ b/tests/e2e/generate-client/examples/cli/redocly.yaml @@ -5,7 +5,7 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript - zod - cli - cli-docs diff --git a/tests/e2e/generate-client/examples/configure-and-middleware/redocly.yaml b/tests/e2e/generate-client/examples/configure-and-middleware/redocly.yaml index e8e7e00d44..2962279525 100644 --- a/tests/e2e/generate-client/examples/configure-and-middleware/redocly.yaml +++ b/tests/e2e/generate-client/examples/configure-and-middleware/redocly.yaml @@ -5,4 +5,4 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript diff --git a/tests/e2e/generate-client/examples/custom-generator/README.md b/tests/e2e/generate-client/examples/custom-generator/README.md index 651b984c34..ef5eede900 100644 --- a/tests/e2e/generate-client/examples/custom-generator/README.md +++ b/tests/e2e/generate-client/examples/custom-generator/README.md @@ -1,11 +1,11 @@ # Custom generator (plugin) example Shows the **experimental** custom-generator API: a `generators` entry that is a path to a local -generator runs alongside the built-in `sdk`, reading the same OpenAPI-derived IR. +generator runs alongside the built-in `typescript`, reading the same OpenAPI-derived IR. - [`route-map-generator.mjs`](./route-map-generator.mjs) — the custom generator. Walks the IR's operations and emits `src/api/client.routes.ts`: `: 'METHOD /path'`. -- [`redocly.yaml`](./redocly.yaml) — `generators: [sdk, ./route-map-generator.mjs]`. +- [`redocly.yaml`](./redocly.yaml) — `generators: [typescript, ./route-map-generator.mjs]`. - [`src/main.ts`](./src/main.ts) — imports both the client and the generated `routes` map. Regenerate from the repo root with `npm run examples:regen -w @redocly/client-generator`; type-check diff --git a/tests/e2e/generate-client/examples/custom-generator/redocly.yaml b/tests/e2e/generate-client/examples/custom-generator/redocly.yaml index 84da4e37a8..4b92c69311 100644 --- a/tests/e2e/generate-client/examples/custom-generator/redocly.yaml +++ b/tests/e2e/generate-client/examples/custom-generator/redocly.yaml @@ -7,5 +7,5 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript - ./route-map-generator.mjs diff --git a/tests/e2e/generate-client/examples/custom-generator/route-map-generator.mjs b/tests/e2e/generate-client/examples/custom-generator/route-map-generator.mjs index 6b404a4467..ae5109a626 100644 --- a/tests/e2e/generate-client/examples/custom-generator/route-map-generator.mjs +++ b/tests/e2e/generate-client/examples/custom-generator/route-map-generator.mjs @@ -1,5 +1,5 @@ // A custom generator (the experimental plugin API). Loaded by the `generators:` list in -// redocly.yaml as a path specifier, it runs alongside the built-in `sdk` and emits a +// redocly.yaml as a path specifier, it runs alongside the built-in `typescript` and emits a // `.routes.ts` map of every operation — `: 'METHOD /path'`. // // The output is a source-text template — the same authoring model every built-in @@ -7,12 +7,12 @@ // TypeScript you would write: // // import { defineGenerator } from '@redocly/client-generator'; -// export default defineGenerator({ name: 'route-map', requires: ['sdk'], run({ model, outputPath }) { … } }); +// export default defineGenerator({ name: 'route-map', requires: ['typescript'], run({ model, outputPath }) { … } }); // // `defineGenerator` is just an identity helper for types, so a plain object works too: export default { name: 'route-map', - requires: ['sdk'], + requires: ['typescript'], run({ model, outputPath }) { const entries = model.services .flatMap((service) => service.operations) diff --git a/tests/e2e/generate-client/examples/custom-generator/src/main.ts b/tests/e2e/generate-client/examples/custom-generator/src/main.ts index e8e1449f2e..f9658e4eff 100644 --- a/tests/e2e/generate-client/examples/custom-generator/src/main.ts +++ b/tests/e2e/generate-client/examples/custom-generator/src/main.ts @@ -1,4 +1,4 @@ -// Consumes both the built-in sdk client and the custom generator's output (`routes`), +// Consumes both the built-in typescript client and the custom generator's output (`routes`), // proving the plugin's file is generated alongside the client and type-checks. import { configure, listMenuItems } from './api/client.js'; import { routes } from './api/client.routes.js'; diff --git a/tests/e2e/generate-client/examples/custom-pagination/redocly.yaml b/tests/e2e/generate-client/examples/custom-pagination/redocly.yaml index f78904a9c5..257bbe5e4e 100644 --- a/tests/e2e/generate-client/examples/custom-pagination/redocly.yaml +++ b/tests/e2e/generate-client/examples/custom-pagination/redocly.yaml @@ -10,4 +10,4 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript diff --git a/tests/e2e/generate-client/examples/ejected-generator/.claude/skills/client-generators/SKILL.md b/tests/e2e/generate-client/examples/ejected-generator/.claude/skills/client-generators/SKILL.md index aa5372fecd..deab5cb79c 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 @@ -11,7 +11,7 @@ in the same pass as the built-ins; select it by path in `redocly.yaml`: ```yaml client: - generators: [sdk, ./generators/my-generator.mjs] + generators: [typescript, ./generators/my-generator.mjs] ``` ## The contract @@ -56,7 +56,7 @@ Users set them per generator name: ```yaml client: - generators: [sdk, ./generators/permissions-matrix.mjs] + generators: [typescript, ./generators/permissions-matrix.mjs] options: permissions-matrix: groupBy: path diff --git a/tests/e2e/generate-client/examples/fetch-functions/README.md b/tests/e2e/generate-client/examples/fetch-functions/README.md index e90c7e2b89..70e0889b7e 100644 --- a/tests/e2e/generate-client/examples/fetch-functions/README.md +++ b/tests/e2e/generate-client/examples/fetch-functions/README.md @@ -1,6 +1,6 @@ # fetch-functions example -Generated TypeScript client (`generators: ['sdk']`), consumed as free +Generated TypeScript client (`generators: ['typescript']`), consumed as free functions (`configure()`, `listMenuItems()`), with `ApiError` handling. ## Run diff --git a/tests/e2e/generate-client/examples/fetch-functions/redocly.yaml b/tests/e2e/generate-client/examples/fetch-functions/redocly.yaml index 0e7e6b995e..33d49a7e21 100644 --- a/tests/e2e/generate-client/examples/fetch-functions/redocly.yaml +++ b/tests/e2e/generate-client/examples/fetch-functions/redocly.yaml @@ -7,4 +7,4 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript diff --git a/tests/e2e/generate-client/examples/mock/README.md b/tests/e2e/generate-client/examples/mock/README.md index 5cd9201929..ecbc274d26 100644 --- a/tests/e2e/generate-client/examples/mock/README.md +++ b/tests/e2e/generate-client/examples/mock/README.md @@ -1,6 +1,6 @@ # mock example -Generated TypeScript client plus **MSW** mocks (`generators: ['sdk', 'mock']`), shown two ways from the +Generated TypeScript client plus **MSW** mocks (`generators: ['typescript', 'mock']`), shown two ways from the same generated `src/api/` and the same `handlers`: - **Browser** (`src/main.ts`) — starts an MSW browser worker with `setupWorker` and renders the result. diff --git a/tests/e2e/generate-client/examples/mock/redocly.yaml b/tests/e2e/generate-client/examples/mock/redocly.yaml index 8a37c931fa..dc52fa997a 100644 --- a/tests/e2e/generate-client/examples/mock/redocly.yaml +++ b/tests/e2e/generate-client/examples/mock/redocly.yaml @@ -7,5 +7,5 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript - mock diff --git a/tests/e2e/generate-client/examples/mock/src/node.ts b/tests/e2e/generate-client/examples/mock/src/node.ts index 8567681783..d79d5e3813 100644 --- a/tests/e2e/generate-client/examples/mock/src/node.ts +++ b/tests/e2e/generate-client/examples/mock/src/node.ts @@ -1,4 +1,4 @@ -// Node counterpart to `main.ts`: the same generated `sdk` + `mock` client and the same +// Node counterpart to `main.ts`: the same generated `typescript` + `mock` client and the same // `handlers`, but driven by msw/node's `setupServer`. Node has no Service Worker, so msw // patches global `fetch` directly instead of registering `public/mockServiceWorker.js`. import { setupServer } from 'msw/node'; diff --git a/tests/e2e/generate-client/examples/multi-instance/redocly.yaml b/tests/e2e/generate-client/examples/multi-instance/redocly.yaml index eb1a5e90e7..dbc9df653b 100644 --- a/tests/e2e/generate-client/examples/multi-instance/redocly.yaml +++ b/tests/e2e/generate-client/examples/multi-instance/redocly.yaml @@ -7,5 +7,5 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript runtime: package diff --git a/tests/e2e/generate-client/examples/nested-facade/README.md b/tests/e2e/generate-client/examples/nested-facade/README.md index 4ea7dbb045..5540995c84 100644 --- a/tests/e2e/generate-client/examples/nested-facade/README.md +++ b/tests/e2e/generate-client/examples/nested-facade/README.md @@ -4,7 +4,7 @@ A resource-grouped call shape — `api.orders.listOrders(…)` — derived from spec's **tags** by a small [custom generator](./nested-facade-generator.mjs) (the experimental plugin API), so the nesting regenerates with the spec instead of living in a hand-maintained facade file. Everything stays fully typed: the -facade just re-exports the sdk's generated functions in nested objects. +facade just re-exports the client's generated functions in nested objects. ## Run diff --git a/tests/e2e/generate-client/examples/nested-facade/nested-facade-generator.mjs b/tests/e2e/generate-client/examples/nested-facade/nested-facade-generator.mjs index ebee143162..e5d0073d47 100644 --- a/tests/e2e/generate-client/examples/nested-facade/nested-facade-generator.mjs +++ b/tests/e2e/generate-client/examples/nested-facade/nested-facade-generator.mjs @@ -1,10 +1,10 @@ -// A custom generator (the experimental plugin API): groups the sdk's generated +// A custom generator (the experimental plugin API): groups the client's generated // free functions by their first tag and emits a nested facade — // `api.orders.listOrders(…)` — derived from the spec, regenerated with it. // // Authored in TypeScript you would write: // import { defineGenerator } from '@redocly/client-generator'; -// export default defineGenerator({ name: 'nested-facade', requires: ['sdk'], run({ model, outputPath }) { … } }); +// export default defineGenerator({ name: 'nested-facade', requires: ['typescript'], run({ model, outputPath }) { … } }); const groupIdent = (tag) => { const ident = tag.replace(/[^A-Za-z0-9_$]/g, '_'); return /^[A-Za-z_$]/.test(ident) ? ident[0].toLowerCase() + ident.slice(1) : `_${ident}`; @@ -12,7 +12,7 @@ const groupIdent = (tag) => { export default { name: 'nested-facade', - requires: ['sdk'], + requires: ['typescript'], run({ model, outputPath }) { const groups = new Map(); for (const op of model.services.flatMap((service) => service.operations)) { diff --git a/tests/e2e/generate-client/examples/nested-facade/redocly.yaml b/tests/e2e/generate-client/examples/nested-facade/redocly.yaml index daf72c0a5b..e2c522315a 100644 --- a/tests/e2e/generate-client/examples/nested-facade/redocly.yaml +++ b/tests/e2e/generate-client/examples/nested-facade/redocly.yaml @@ -9,5 +9,5 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript - ./nested-facade-generator.mjs diff --git a/tests/e2e/generate-client/examples/nested-facade/src/main.ts b/tests/e2e/generate-client/examples/nested-facade/src/main.ts index db810ba43d..0449c70a69 100644 --- a/tests/e2e/generate-client/examples/nested-facade/src/main.ts +++ b/tests/e2e/generate-client/examples/nested-facade/src/main.ts @@ -1,7 +1,7 @@ import { api } from './api/client.facade.js'; // nested-facade — a resource-grouped client shape, generated from the spec's tags. // -// The generated sdk exposes flat functions and the `client` instance; some teams +// The generated client exposes flat functions and the `client` instance; some teams // prefer `api..(…)`. Instead of hand-maintaining that facade, // the custom generator in ./nested-facade-generator.mjs derives it from the spec's // tags — every regeneration keeps it in sync, and everything stays fully typed. diff --git a/tests/e2e/generate-client/examples/node-native/redocly.yaml b/tests/e2e/generate-client/examples/node-native/redocly.yaml index cab21aa9df..8ffd27e6f0 100644 --- a/tests/e2e/generate-client/examples/node-native/redocly.yaml +++ b/tests/e2e/generate-client/examples/node-native/redocly.yaml @@ -5,6 +5,6 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript outputMode: split importExt: ts diff --git a/tests/e2e/generate-client/examples/package-runtime/redocly.yaml b/tests/e2e/generate-client/examples/package-runtime/redocly.yaml index 5aa5ec8bbd..2ba6882016 100644 --- a/tests/e2e/generate-client/examples/package-runtime/redocly.yaml +++ b/tests/e2e/generate-client/examples/package-runtime/redocly.yaml @@ -8,5 +8,5 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript runtime: package diff --git a/tests/e2e/generate-client/examples/pagination/redocly.yaml b/tests/e2e/generate-client/examples/pagination/redocly.yaml index 9d6b34bc23..832ddeebf0 100644 --- a/tests/e2e/generate-client/examples/pagination/redocly.yaml +++ b/tests/e2e/generate-client/examples/pagination/redocly.yaml @@ -10,7 +10,7 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript pagination: style: cursor cursorParam: cursor diff --git a/tests/e2e/generate-client/examples/programmatic/generate.ts b/tests/e2e/generate-client/examples/programmatic/generate.ts index 2984185f0f..c1ddd005ad 100644 --- a/tests/e2e/generate-client/examples/programmatic/generate.ts +++ b/tests/e2e/generate-client/examples/programmatic/generate.ts @@ -13,7 +13,7 @@ const result = await generateClient({ outputMode: 'single', // 'single' | 'split' argsStyle: 'flat', // 'flat' | 'grouped' errorMode: 'throw', // 'throw' | 'result' - generators: ['sdk'], // add 'zod' | 'tanstack-query' | 'transformers' + generators: ['typescript'], // add 'zod' | 'tanstack-query' | 'transformers' }); console.log(`Wrote ${result.files.length} file(s), ${result.bytes} bytes to ${result.outputPath}`); diff --git a/tests/e2e/generate-client/examples/sse-streaming/redocly.yaml b/tests/e2e/generate-client/examples/sse-streaming/redocly.yaml index 29ff096d03..ae16139b8e 100644 --- a/tests/e2e/generate-client/examples/sse-streaming/redocly.yaml +++ b/tests/e2e/generate-client/examples/sse-streaming/redocly.yaml @@ -5,4 +5,4 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript diff --git a/tests/e2e/generate-client/examples/tanstack-query/README.md b/tests/e2e/generate-client/examples/tanstack-query/README.md index 19fbc36c6b..113253cb13 100644 --- a/tests/e2e/generate-client/examples/tanstack-query/README.md +++ b/tests/e2e/generate-client/examples/tanstack-query/README.md @@ -1,7 +1,7 @@ # tanstack-query example Generated TypeScript client plus **TanStack Query** (React) factories -(`generators: ['sdk', 'tanstack-query']`). The app uses `useQuery(Options())` under a +(`generators: ['typescript', 'tanstack-query']`). The app uses `useQuery(Options())` under a `QueryClientProvider`. ## Run diff --git a/tests/e2e/generate-client/examples/tanstack-query/redocly.yaml b/tests/e2e/generate-client/examples/tanstack-query/redocly.yaml index 7572c29f9d..053b6cc28f 100644 --- a/tests/e2e/generate-client/examples/tanstack-query/redocly.yaml +++ b/tests/e2e/generate-client/examples/tanstack-query/redocly.yaml @@ -7,5 +7,5 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript - tanstack-query diff --git a/tests/e2e/generate-client/examples/typescript-types-generator/README.md b/tests/e2e/generate-client/examples/typescript-types-generator/README.md index f7e64e70a3..4825f222b1 100644 --- a/tests/e2e/generate-client/examples/typescript-types-generator/README.md +++ b/tests/e2e/generate-client/examples/typescript-types-generator/README.md @@ -20,7 +20,7 @@ use, so the mapping matches the generated client exactly, instead of guessing at }; ``` -- [`redocly.yaml`](./redocly.yaml) — `generators: [sdk, ./response-map-generator.mjs]`. +- [`redocly.yaml`](./redocly.yaml) — `generators: [typescript, ./response-map-generator.mjs]`. - [`src/main.ts`](./src/main.ts) — proves the map matches the client: `ResponseShapes['listMenuItems']` is exactly what `listMenuItems()` resolves to. diff --git a/tests/e2e/generate-client/examples/typescript-types-generator/redocly.yaml b/tests/e2e/generate-client/examples/typescript-types-generator/redocly.yaml index e5fee3b2bc..b88da353a4 100644 --- a/tests/e2e/generate-client/examples/typescript-types-generator/redocly.yaml +++ b/tests/e2e/generate-client/examples/typescript-types-generator/redocly.yaml @@ -7,5 +7,5 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript - ./response-map-generator.mjs 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 b753f643ba..0f5a8a34bd 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 @@ -1,6 +1,6 @@ // A custom generator that renders real TypeScript TYPES with the // `@redocly/client-generator/generate` text toolkit — `tsType` is the same -// schema→type renderer the built-in sdk uses (refs, arrays, unions, formats, +// schema→type renderer the built-in typescript generator uses (refs, arrays, unions, formats, // parenthesization), so the output matches the generated client's types exactly. // It emits `.responses.ts`: a `ResponseShapes` type mapping every // operation to the TypeScript type of its primary JSON success body. @@ -9,12 +9,12 @@ // // import { defineGenerator } from '@redocly/client-generator'; // import { tsType } from '@redocly/client-generator/generate'; -// export default defineGenerator({ name: 'response-map', requires: ['sdk'], run({ model, outputPath }) { … } }); +// export default defineGenerator({ name: 'response-map', requires: ['typescript'], run({ model, outputPath }) { … } }); import { tsType } from '@redocly/client-generator/generate'; export default { name: 'response-map', - requires: ['sdk'], + requires: ['typescript'], run({ model, outputPath }) { // Every operation with a JSON success body — a 204 or an image download has no entry. const withJsonBody = model.services diff --git a/tests/e2e/generate-client/examples/typescript-types-generator/src/main.ts b/tests/e2e/generate-client/examples/typescript-types-generator/src/main.ts index 48fdc47865..e6b8031e5d 100644 --- a/tests/e2e/generate-client/examples/typescript-types-generator/src/main.ts +++ b/tests/e2e/generate-client/examples/typescript-types-generator/src/main.ts @@ -1,4 +1,4 @@ -// Consumes the sdk client alongside the custom generator's `ResponseShapes` map — +// Consumes the typescript client alongside the custom generator's `ResponseShapes` map — // the annotation below only compiles because the map's entry IS the type // `listMenuItems()` resolves to, proving the AST-built output stays in sync with the client. import { configure, listMenuItems } from './api/client.js'; diff --git a/tests/e2e/generate-client/examples/vendored-edge/redocly.yaml b/tests/e2e/generate-client/examples/vendored-edge/redocly.yaml index 7bf35132be..88d4da2c8b 100644 --- a/tests/e2e/generate-client/examples/vendored-edge/redocly.yaml +++ b/tests/e2e/generate-client/examples/vendored-edge/redocly.yaml @@ -7,4 +7,4 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript diff --git a/tests/e2e/generate-client/examples/zero-install-quickstart/redocly.yaml b/tests/e2e/generate-client/examples/zero-install-quickstart/redocly.yaml index be28cd8da3..a19c468869 100644 --- a/tests/e2e/generate-client/examples/zero-install-quickstart/redocly.yaml +++ b/tests/e2e/generate-client/examples/zero-install-quickstart/redocly.yaml @@ -5,4 +5,4 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript diff --git a/tests/e2e/generate-client/examples/zod/README.md b/tests/e2e/generate-client/examples/zod/README.md index bbac00272b..8215a19cbc 100644 --- a/tests/e2e/generate-client/examples/zod/README.md +++ b/tests/e2e/generate-client/examples/zod/README.md @@ -1,6 +1,6 @@ # zod example -Generated TypeScript client plus **zod** schemas (`generators: ['sdk', 'zod']`). +Generated TypeScript client plus **zod** schemas (`generators: ['typescript', 'zod']`). The app turns on `zodValidation()` — request bodies and JSON responses are validated against the generated schemas on every call — and also uses a schema directly. diff --git a/tests/e2e/generate-client/examples/zod/redocly.yaml b/tests/e2e/generate-client/examples/zod/redocly.yaml index fe808914ae..5a816756d8 100644 --- a/tests/e2e/generate-client/examples/zod/redocly.yaml +++ b/tests/e2e/generate-client/examples/zod/redocly.yaml @@ -7,5 +7,5 @@ apis: clientOutput: ./src/api/client.ts client: generators: - - sdk + - typescript - zod diff --git a/tests/e2e/generate-client/fixtures/route-map-plugin.mjs b/tests/e2e/generate-client/fixtures/route-map-plugin.mjs index 202f141b3d..8349875d80 100644 --- a/tests/e2e/generate-client/fixtures/route-map-plugin.mjs +++ b/tests/e2e/generate-client/fixtures/route-map-plugin.mjs @@ -2,7 +2,7 @@ // compiled CLI can import it under bare `node`. Emits a `.routes.ts` map of every operation. export default { name: 'route-map', - requires: ['sdk'], + requires: ['typescript'], // Declared options: the config block is validated against this before `run`. options: { type: 'object', diff --git a/tests/e2e/generate-client/generator-contract.test.ts b/tests/e2e/generate-client/generator-contract.test.ts index f6c9a1ce01..1a8ac056d9 100644 --- a/tests/e2e/generate-client/generator-contract.test.ts +++ b/tests/e2e/generate-client/generator-contract.test.ts @@ -64,7 +64,7 @@ describe('generate-client generator compatibility contract', () => { '--output', join(dir, 'c.ts'), '--generator', - 'sdk', + 'typescript', '--generator', 'tanstack-query', '--error-mode', @@ -82,7 +82,7 @@ describe('generate-client generator compatibility contract', () => { '--output', join(dir, 'c.ts'), '--generator', - 'sdk', + 'typescript', '--generator', 'transformers', ]); @@ -110,7 +110,7 @@ describe('generate-client generator compatibility contract', () => { '--output', join(dir, 'c.ts'), '--generator', - 'sdk', + 'typescript', '--generator', 'tanstack-query', ]); @@ -153,7 +153,7 @@ describe('generate-client generator compatibility contract', () => { '--output', join(dir, 'c.ts'), '--generator', - 'sdk', + 'typescript', '--generator', 'tanstack-query', ]); diff --git a/tests/e2e/generate-client/large-descriptions.test.ts b/tests/e2e/generate-client/large-descriptions.test.ts index 6c5c0d5383..89083cf2d1 100644 --- a/tests/e2e/generate-client/large-descriptions.test.ts +++ b/tests/e2e/generate-client/large-descriptions.test.ts @@ -52,7 +52,7 @@ function generateWith(generator: string | string[], description: string): string /** TS bar: the generated client passes a strict `tsc --noEmit`. */ function typescriptBar(description: string): void { - const dir = generateWith('sdk', description); + const dir = generateWith('typescript', description); writeFileSync(join(dir, 'package.json'), JSON.stringify({ type: 'module' }), 'utf-8'); strictTypecheck(dir); } @@ -63,7 +63,7 @@ function typescriptBar(description: string): void { * path to `zod` — taken from the repo, like `@types/node` below. */ function cliBar(description: string): void { - const dir = generateWith(['sdk', 'cli'], description); + const dir = generateWith(['typescript', 'cli'], description); writeFileSync(join(dir, 'package.json'), JSON.stringify({ type: 'module' }), 'utf-8'); // The temp dir sits outside the repo, so node resolution finds nothing: borrow the // repo's node_modules for `zod` (the CLI's validation) and `@types/node`. diff --git a/tests/e2e/generate-client/mock.test.ts b/tests/e2e/generate-client/mock.test.ts index e575d12ff5..83daf0eeb0 100644 --- a/tests/e2e/generate-client/mock.test.ts +++ b/tests/e2e/generate-client/mock.test.ts @@ -1,5 +1,5 @@ /** - * Behavioral e2e for the `mock` generator. We generate `sdk,mock` into a temp dir, + * Behavioral e2e for the `mock` generator. We generate `typescript,mock` into a temp dir, * then run a real consumer (via tsx) that installs the emitted MSW handlers into * `setupServer` and calls a generated client operation whose native `fetch` MSW * intercepts. With `onUnhandledRequest: 'error'`, a resolved call proves interception @@ -29,7 +29,7 @@ describe('mock generator — generated client through MSW', () => { // relative to the importing file, so the temp dir must live inside the repo // tree to walk up to the root node_modules — `os.tmpdir()` would not resolve it. dir = mkdtempSync(join(__dirname, 'mock-consumer-')); - generateInto(dir, fixture, ['--generator', 'sdk', '--generator', 'mock']); + generateInto(dir, fixture, ['--generator', 'typescript', '--generator', 'mock']); }, 60_000); afterAll(() => { if (dir && existsSync(dir)) rmSync(dir, { recursive: true, force: true }); @@ -102,7 +102,7 @@ describe('mock generator — mock + transformers + --date-type Date compile toge // so the mock sampler must bake `new Date(...)` to type-check (BUG 1 regression). generateInto(dir, dateFixture, [ '--generator', - 'sdk', + 'typescript', '--generator', 'mock', '--generator', @@ -148,7 +148,7 @@ describe('mock generator — faker mode strict-tsc-checks against real @faker-js dir = mkdtempSync(join(__dirname, 'mock-faker-')); generateInto(dir, fixture, [ '--generator', - 'sdk', + 'typescript', '--generator', 'mock', '--mock-data', diff --git a/tests/e2e/generate-client/package-mode.test.ts b/tests/e2e/generate-client/package-mode.test.ts index 1f1adfe260..f8a6605be2 100644 --- a/tests/e2e/generate-client/package-mode.test.ts +++ b/tests/e2e/generate-client/package-mode.test.ts @@ -157,7 +157,7 @@ describe('generate-client package-runtime consumer', () => { api: fixture, output: tanstackEntry, runtime: 'package', - generators: ['sdk', 'tanstack-query'], + generators: ['typescript', 'tanstack-query'], }); expect(existsSync(tanstackEntry)).toBe(true); expect(readFileSync(tanstackEntry, 'utf-8')).toContain("from '@redocly/client-generator'"); @@ -185,7 +185,7 @@ describe('generate-client package-runtime consumer', () => { output, runtime: 'package', argsStyle: 'grouped', - generators: ['sdk', 'tanstack-query'], + generators: ['typescript', 'tanstack-query'], }); writeFileSync( join(dir, 'tsconfig.json'), diff --git a/tests/e2e/generate-client/plugin.test.ts b/tests/e2e/generate-client/plugin.test.ts index d5af0f6dcb..8ec0b46f97 100644 --- a/tests/e2e/generate-client/plugin.test.ts +++ b/tests/e2e/generate-client/plugin.test.ts @@ -29,7 +29,7 @@ describe('generate-client custom generator (plugin) API', () => { '--output', output, '--generator', - 'sdk', + 'typescript', '--generator', plugin, ]); @@ -59,7 +59,7 @@ describe('generate-client custom generator (plugin) API', () => { '--output', join(dir, 'client.ts'), '--generator', - 'sdk', + 'typescript', '--generator', './route-map-plugin.mjs', '--config', @@ -80,7 +80,7 @@ describe('generate-client custom generator (plugin) API', () => { const writeConfig = (options: string) => writeFileSync( config, - `extends: []\nclient:\n generators: [sdk, ./route-map-plugin.mjs]\n options:\n route-map:\n${options}` + `extends: []\nclient:\n generators: [typescript, ./route-map-plugin.mjs]\n options:\n route-map:\n${options}` ); writeConfig(' exportName: paths\n'); @@ -112,7 +112,7 @@ describe('generate-client custom generator (plugin) API', () => { '--output', join(dir, 'client.ts'), '--generator', - 'sdk', + 'typescript', '--generator', join(dir, 'missing-plugin.mjs'), ]); diff --git a/tests/e2e/generate-client/redocly-config.test.ts b/tests/e2e/generate-client/redocly-config.test.ts index b031256638..2e33add89e 100644 --- a/tests/e2e/generate-client/redocly-config.test.ts +++ b/tests/e2e/generate-client/redocly-config.test.ts @@ -75,14 +75,14 @@ describe('generate-client redocly.yaml config', () => { const dir = project( [ 'client:', // shared defaults, inherited by apis without their own block - ' generators: [sdk]', + ' generators: [typescript]', ' serverUrl: https://shared.example.com', 'apis:', ' cafe:', ' root: ./openapi.yaml', ' clientOutput: ./src/cafe.ts', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ' outputOnly:', // `clientOutput` alone also opts in ' root: ./openapi.yaml', ' clientOutput: ./src/output-only.ts', @@ -111,7 +111,7 @@ describe('generate-client redocly.yaml config', () => { ' cafe:', ' root: ./openapi.yaml', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ].join('\n') + '\n' ); const res = run(dir); @@ -128,7 +128,7 @@ describe('generate-client redocly.yaml config', () => { ' root: ./openapi.yaml', ' clientOutput: ./out.ts', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ' serverUrl: https://per-api.example.com', ].join('\n') + '\n' ); @@ -144,7 +144,7 @@ describe('generate-client redocly.yaml config', () => { const dir = project( [ 'client:', - ' generators: [sdk]', + ' generators: [typescript]', ' serverUrl: https://top-level.example.com', 'apis:', ' cafe:', @@ -169,7 +169,7 @@ describe('generate-client redocly.yaml config', () => { ' root: ./openapi.yaml', ' clientOutput: ./out.ts', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ' codeSamples: true', ].join('\n') + '\n' ); @@ -187,7 +187,7 @@ describe('generate-client redocly.yaml config', () => { const dir = project( [ 'client:', - ' generators: [sdk, zod]', + ' generators: [typescript, zod]', ' errorMode: result', 'apis:', ' cafe:', @@ -213,13 +213,13 @@ describe('generate-client redocly.yaml config', () => { const dir = project( [ 'client:', - ' generators: [sdk]', + ' generators: [typescript]', ' serverUrl: https://top-level.example.com', 'apis:', ' cafe:', ' root: ./openapi.yaml', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ' serverUrl: https://per-api.example.com', ].join('\n') + '\n' ); @@ -246,7 +246,7 @@ describe('generate-client redocly.yaml config', () => { ' root: ./openapi.yaml', ' clientOutput: ./out.ts', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ' serverUrl: https://per-api.example.com', ].join('\n') + '\n' ); @@ -266,7 +266,7 @@ describe('generate-client redocly.yaml config', () => { ' root: ./openapi.yaml', ' clientOutput: ./out.ts', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ' facade: service-class', // removed option -> property-not-expected warning ].join('\n') + '\n' ); @@ -286,7 +286,7 @@ describe('generate-client redocly.yaml config', () => { ' root: ./openapi.yaml', ' clientOutput: ./out.ts', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ' pagination:', ' style: cursor', ' cursorParam: after', @@ -315,7 +315,7 @@ describe('generate-client redocly.yaml config', () => { ' root: ./openapi.yaml', ' clientOutput: ./out.ts', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ' pagination:', ' operations:', ' getRevenue:', // has no `after` query param -> explicit misfit = error @@ -342,7 +342,7 @@ describe('generate-client redocly.yaml config', () => { ' root: ./openapi.yaml', ' clientOutput: ./out.ts', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ' pagination:', ' style: cursor', ' cursor_param: after', // unknown key -> property-not-expected warning @@ -366,7 +366,7 @@ describe('generate-client redocly.yaml config', () => { ' cafe:', ' root: ./openapi.yaml', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ].join('\n') + '\n' ); const res = run(dir, ['--output', './out.ts']); @@ -390,16 +390,16 @@ describe('generate-client redocly.yaml config', () => { ' a:', ' root: ./openapi.yaml', ' clientOutput: ./dupe.ts', - ' client: { generators: [sdk] }', + ' client: { generators: [typescript] }', ' b:', ' root: ./openapi.yaml', ' clientOutput: ./dupe.ts', - ' client: { generators: [sdk] }', + ' client: { generators: [typescript] }', ].join('\n') + '\n' ); const res = run(dir); expect(res.status).not.toBe(0); - expect(res.stderr).toContain('resolve to the same output path'); + expect(res.stderr).toContain('Two APIs write to the same path'); rmSync(dir, { recursive: true, force: true }); }, 60_000); @@ -412,7 +412,7 @@ describe('generate-client redocly.yaml config', () => { ' root: ./openapi.yaml', ' clientOutput: ./out.ts', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ` serverUrl: ${serverUrl}`, ].join('\n') + '\n' ); @@ -448,7 +448,7 @@ describe('generate-client redocly.yaml config', () => { ' root: ./openapi.yaml', ' clientOutput: ./out.ts', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ' setup: https://cdn.example.com/setup.ts', ].join('\n') + '\n' ); @@ -466,7 +466,7 @@ describe('generate-client redocly.yaml config', () => { ' root: ./openapi.yaml', ' clientOutput: ./out.ts', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ' runtime: package', ].join('\n') + '\n' ); @@ -486,7 +486,7 @@ describe('generate-client redocly.yaml config', () => { ' root: ./openapi.yaml', ' clientOutput: ./out/client.ts', ' client:', - ' generators: [sdk, zod]', + ' generators: [typescript, zod]', ' outputMode: split', ' runtime: package', ].join('\n') + '\n' @@ -510,7 +510,7 @@ describe('generate-client redocly.yaml config', () => { ' root: ./openapi.yaml', ' clientOutput: ./out/client.ts', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ].join('\n') + '\n' ); // Run from the repo root, pointing at the config elsewhere via --config. @@ -552,7 +552,7 @@ describe('generate-client redocly.yaml config', () => { ' decorators:', ' remove-x-internal: on', ' client:', - ' generators: [sdk]', + ' generators: [typescript]', ].join('\n') + '\n', 'utf-8' ); diff --git a/tests/e2e/generate-client/swr.test.ts b/tests/e2e/generate-client/swr.test.ts index 3790d53a3a..b9af13b646 100644 --- a/tests/e2e/generate-client/swr.test.ts +++ b/tests/e2e/generate-client/swr.test.ts @@ -20,7 +20,7 @@ describe('generate-client swr generator', () => { generate(join(__dirname, 'fixtures', 'base.yaml'), out, [ '--generator', - 'sdk', + 'typescript', '--generator', 'swr', ]); diff --git a/tests/e2e/generate-client/tanstack-query.runtime.test.ts b/tests/e2e/generate-client/tanstack-query.runtime.test.ts index befc7b3d43..c1f4f52c6c 100644 --- a/tests/e2e/generate-client/tanstack-query.runtime.test.ts +++ b/tests/e2e/generate-client/tanstack-query.runtime.test.ts @@ -2,7 +2,7 @@ // // Tier-3 runtime React-hook integration for the tanstack-query generator. // -// MECHANISM (documented choice): we generate `sdk,tanstack-query` into a fixed, +// MECHANISM (documented choice): we generate `typescript,tanstack-query` into a fixed, // checked-in consumer dir (`tanstack-consumer/`) and dynamic-`import()` the // generated `client.tanstack.ts` directly — vite transforms it and resolves its // `./client.js` import to the sibling `.ts` reliably (verified). The data is @@ -48,7 +48,7 @@ describe('generate-client tanstack-query runtime (React hooks, jsdom)', () => { } generate(join(__dirname, 'fixtures', 'base.yaml'), sdkFile, [ '--generator', - 'sdk', + 'typescript', '--generator', 'tanstack-query', ]); diff --git a/tests/e2e/generate-client/tanstack-query.test.ts b/tests/e2e/generate-client/tanstack-query.test.ts index ea8dfbcbe0..a8c042e2fa 100644 --- a/tests/e2e/generate-client/tanstack-query.test.ts +++ b/tests/e2e/generate-client/tanstack-query.test.ts @@ -23,7 +23,7 @@ describe('generate-client tanstack-query generator', () => { generate(join(__dirname, 'fixtures', 'base.yaml'), out, [ '--generator', - 'sdk', + 'typescript', '--generator', 'tanstack-query', ]); @@ -104,7 +104,7 @@ describe('generate-client tanstack-query generator', () => { '--runtime', 'package', '--generator', - 'sdk', + 'typescript', '--generator', 'tanstack-query', ]); @@ -177,7 +177,7 @@ describe('generate-client tanstack-query generator', () => { generate(join(__dirname, 'fixtures', 'base.yaml'), out, [ '--generator', - 'sdk', + 'typescript', '--generator', 'tanstack-query-vue', ]); diff --git a/tests/e2e/generate-client/transformers.test.ts b/tests/e2e/generate-client/transformers.test.ts index 5f9b34793c..54495ea9b5 100644 --- a/tests/e2e/generate-client/transformers.test.ts +++ b/tests/e2e/generate-client/transformers.test.ts @@ -2,7 +2,7 @@ // e2e for the `transformers` generator paired with the sdk `--date-type Date` // knob. Two tiers: // -// - TYPE-CHECK: generate `sdk,transformers --date-type Date` into a temp dir, +// - TYPE-CHECK: generate `typescript,transformers --date-type Date` into a temp dir, // assert the sdk types `Date` for date fields and the transformers module has // `transform` with `new Date(`, then strict-`tsc` `client.ts` + // `client.transformers.ts` TOGETHER. tsc exit 0 proves each generated @@ -43,7 +43,7 @@ describe('generate-client transformers generator', () => { const out = join(dir, 'client.ts'); const transformersOut = join(dir, 'client.transformers.ts'); - generate(out, ['sdk,transformers', '--date-type', 'Date']); + generate(out, ['typescript,transformers', '--date-type', 'Date']); expect(existsSync(out)).toBe(true); expect(existsSync(transformersOut)).toBe(true); @@ -70,7 +70,7 @@ describe('generate-client transformers generator', () => { const transformersFile = join(consumerDir, 'client.transformers.ts'); for (const f of [sdkFile, transformersFile]) if (existsSync(f)) rmSync(f, { force: true }); - generate(sdkFile, ['sdk,transformers', '--date-type', 'Date']); + generate(sdkFile, ['typescript,transformers', '--date-type', 'Date']); expect(existsSync(transformersFile)).toBe(true); const mod = await import(transformersFile); @@ -98,7 +98,7 @@ describe('generate-client transformers generator', () => { it('without --date-type Date the sdk date field stays typed string (default)', () => { const dir = mkdtempSync(join(tmpdir(), 'ots-transformers-default-')); const out = join(dir, 'client.ts'); - generate(out, ['sdk']); + generate(out, ['typescript']); expect(readFileSync(out, 'utf-8')).toContain('createdAt?: string;'); rmSync(dir, { recursive: true, force: true }); }, 60_000); diff --git a/tests/e2e/generate-client/zod.test.ts b/tests/e2e/generate-client/zod.test.ts index eaa28670f1..3b458eeb5a 100644 --- a/tests/e2e/generate-client/zod.test.ts +++ b/tests/e2e/generate-client/zod.test.ts @@ -28,7 +28,7 @@ describe('generate-client zod generator', () => { generate(join(__dirname, 'fixtures', 'cafe.yaml'), out, [ '--generator', - 'sdk', + 'typescript', '--generator', 'zod', ]); @@ -140,7 +140,7 @@ describe('generate-client zod generator', () => { ); generate(join(dir, 'openapi.yaml'), join(dir, 'client.ts'), [ '--generator', - 'sdk', + 'typescript', '--generator', 'zod', ]); From d3c7d227cc7241c59cb2ba9de5dcbe2494747d9d Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Thu, 13 Aug 2026 14:58:19 +0300 Subject: [PATCH 159/211] =?UTF-8?q?fix:=20address=20CI=20findings=20?= =?UTF-8?q?=E2=80=94=20extract=20the=20update=20and=20composed-entry=20flo?= =?UTF-8?q?ws=20below=20the=20complexity=20limit,=20and=20slug=20names=20w?= =?UTF-8?q?ithout=20backtracking=20regexes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/cli/src/commands/eject-generator.ts | 174 +++++++----- packages/cli/src/commands/generate-client.ts | 268 +++++++++++------- .../src/emitters/runtime-sources.ts | 4 +- .../src/generators/cli/index.ts | 7 +- packages/client-generator/src/runtime/cli.ts | 8 +- 5 files changed, 267 insertions(+), 194 deletions(-) diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts index de5eb42fbd..14ac53c92e 100644 --- a/packages/cli/src/commands/eject-generator.ts +++ b/packages/cli/src/commands/eject-generator.ts @@ -372,6 +372,104 @@ export function wireConfig(configPath: string | undefined, name: string, entry: return true; } +/** + * The `--update` flow: three-way-merge the newer built-in version into the user's copy, + * merging the two skills the same way, and report the conflict count. + */ +function updateEjectedGenerator({ + name, + asset, + toolkitVersion, + assetsDir, + dir, + target, + printedTarget, +}: { + name: string; + asset: string; + toolkitVersion: string; + assetsDir: string; + dir: string; + target: string; + printedTarget: string; +}): void { + if (!existsSync(target)) { + ejectGeneratorTelemetry.eject_generator_outcome = 'missing-target'; + throw new HandledError( + `\n❌ Nothing to update: ${printedTarget} does not exist. Eject first.\n` + ); + } + // Ejects before the base moved to the registry left a snapshot behind; it still works + // as the base, which keeps `--update` offline for anyone mid-migration. + const legacyBase = join(dir, '.pristine', `${name}.mjs`); + const customized = readFileSync(target, 'utf-8'); + const from = recordedVersion(customized); + // Version distance behind the conflict count — both OUR versions. The header is + // user-editable text, so it's recorded only when it parses as a semver version. + if (from !== undefined && semver.valid(from) !== null) { + ejectGeneratorTelemetry.eject_generator_from_version = from; + } + ejectGeneratorTelemetry.eject_generator_to_version = toolkitVersion; + // One pack fetches every merge base: the generator plus both skills it shipped with. + const packed = + existsSync(legacyBase) || from === toolkitVersion || from === undefined + ? new Map() + : packedAssets(`${TOOLKIT_PACKAGE}@${from}`, [ + generatorMember(name), + skillMember('client-generators'), + skillMember(`${name}-generator`), + ]); + const base = existsSync(legacyBase) + ? readFileSync(legacyBase, 'utf-8') + : from === toolkitVersion + ? asset + : packed.get(generatorMember(name)); + if (base === undefined) { + ejectGeneratorTelemetry.eject_generator_outcome = 'missing-base'; + const sideBySide = `${target}.new`; + writeFileSync(sideBySide, asset, 'utf-8'); + throw new HandledError( + `\n❌ Could not read the version this file was ejected from (${from ?? 'not recorded in its header'}), so there is no merge base.\n` + + ` The current generator is written to ${relative(process.cwd(), sideBySide)} — diff it against your copy and merge by hand.\n` + ); + } + const { merged, conflicts } = threeWayMerge(customized, base, asset); + writeFileSync(target, merged, 'utf-8'); + if (existsSync(legacyBase)) { + logger.info( + `Used ${relative(process.cwd(), legacyBase)} as the merge base. Later updates read the version from the file's header, so you can delete that .pristine directory.\n` + ); + } + // The skills are edit-first files too, so they merge the same way the generator did. + const skillBase = (skill: string): string | undefined => + from === toolkitVersion + ? readFileSync(join(assetsDir, 'skills', skill, 'SKILL.md'), 'utf-8') + : packed.get(skillMember(skill)); + const skillConflicts = + updateSkill('client-generators', assetsDir, skillBase('client-generators')) + + updateSkill(`${name}-generator`, assetsDir, skillBase(`${name}-generator`)); + dropPointer(dir, ejectedIn(dir)); + // The merged file targets the new toolkit; a range recorded at eject time may not. + const dependency = wireDependency({ [TOOLKIT_PACKAGE]: toolkitVersion }, true); + if (dependency === 'updated' || dependency === 'added') { + logger.info( + `Set ${TOOLKIT_PACKAGE} to ^${toolkitVersion} in package.json — run your installer.\n` + ); + } + const totalConflicts = conflicts + skillConflicts; + ejectGeneratorTelemetry.eject_generator_outcome = totalConflicts > 0 ? 'conflicts' : 'success'; + if (totalConflicts > 0) { + ejectGeneratorTelemetry.eject_generator_conflicts = totalConflicts; + logger.warn( + `Updated ${printedTarget} with ${totalConflicts} conflict(s)${ + skillConflicts > 0 ? ' (some in .claude/skills)' : '' + } — resolve the <<<<<<< markers, then regenerate.\n` + ); + } else { + logger.info(`Updated ${printedTarget} cleanly.\n`); + } +} + export const handleEjectGenerator = async ({ argv, config, @@ -412,84 +510,10 @@ export const handleEjectGenerator = async ({ const { GENERATOR_VERSION: toolkitVersion } = await import('@redocly/client-generator'); const dir = resolve(argv.dir ?? './generators'); const target = join(dir, `${name}.mjs`); - // Ejects before the base moved to the registry left a snapshot behind; it still works - // as the base, which keeps `--update` offline for anyone mid-migration. - const legacyBase = join(dir, '.pristine', `${name}.mjs`); const printedTarget = relative(process.cwd(), target) || target; if (argv.update) { - if (!existsSync(target)) { - ejectGeneratorTelemetry.eject_generator_outcome = 'missing-target'; - throw new HandledError( - `\n❌ Nothing to update: ${printedTarget} does not exist. Eject first.\n` - ); - } - const customized = readFileSync(target, 'utf-8'); - const from = recordedVersion(customized); - // Version distance behind the conflict count — both OUR versions. The header is - // user-editable text, so it's recorded only when it parses as a semver version. - if (from !== undefined && semver.valid(from) !== null) { - ejectGeneratorTelemetry.eject_generator_from_version = from; - } - ejectGeneratorTelemetry.eject_generator_to_version = toolkitVersion; - // One pack fetches every merge base: the generator plus both skills it shipped with. - const packed = - existsSync(legacyBase) || from === toolkitVersion || from === undefined - ? new Map() - : packedAssets(`${TOOLKIT_PACKAGE}@${from}`, [ - generatorMember(name), - skillMember('client-generators'), - skillMember(`${name}-generator`), - ]); - const base = existsSync(legacyBase) - ? readFileSync(legacyBase, 'utf-8') - : from === toolkitVersion - ? asset - : packed.get(generatorMember(name)); - if (base === undefined) { - ejectGeneratorTelemetry.eject_generator_outcome = 'missing-base'; - const sideBySide = `${target}.new`; - writeFileSync(sideBySide, asset, 'utf-8'); - throw new HandledError( - `\n❌ Could not read the version this file was ejected from (${from ?? 'not recorded in its header'}), so there is no merge base.\n` + - ` The current generator is written to ${relative(process.cwd(), sideBySide)} — diff it against your copy and merge by hand.\n` - ); - } - const { merged, conflicts } = threeWayMerge(customized, base, asset); - writeFileSync(target, merged, 'utf-8'); - if (existsSync(legacyBase)) { - logger.info( - `Used ${relative(process.cwd(), legacyBase)} as the merge base. Later updates read the version from the file's header, so you can delete that .pristine directory.\n` - ); - } - // The skills are edit-first files too, so they merge the same way the generator did. - const skillBase = (skill: string): string | undefined => - from === toolkitVersion - ? readFileSync(join(assetsDir, 'skills', skill, 'SKILL.md'), 'utf-8') - : packed.get(skillMember(skill)); - const skillConflicts = - updateSkill('client-generators', assetsDir, skillBase('client-generators')) + - updateSkill(`${name}-generator`, assetsDir, skillBase(`${name}-generator`)); - dropPointer(dir, ejectedIn(dir)); - // The merged file targets the new toolkit; a range recorded at eject time may not. - const dependency = wireDependency({ [TOOLKIT_PACKAGE]: toolkitVersion }, true); - if (dependency === 'updated' || dependency === 'added') { - logger.info( - `Set ${TOOLKIT_PACKAGE} to ^${toolkitVersion} in package.json — run your installer.\n` - ); - } - const totalConflicts = conflicts + skillConflicts; - ejectGeneratorTelemetry.eject_generator_outcome = totalConflicts > 0 ? 'conflicts' : 'success'; - if (totalConflicts > 0) { - ejectGeneratorTelemetry.eject_generator_conflicts = totalConflicts; - logger.warn( - `Updated ${printedTarget} with ${totalConflicts} conflict(s)${ - skillConflicts > 0 ? ' (some in .claude/skills)' : '' - } — resolve the <<<<<<< markers, then regenerate.\n` - ); - } else { - logger.info(`Updated ${printedTarget} cleanly.\n`); - } + updateEjectedGenerator({ name, asset, toolkitVersion, assetsDir, dir, target, printedTarget }); return; } diff --git a/packages/cli/src/commands/generate-client.ts b/packages/cli/src/commands/generate-client.ts index b56989a710..16d77be6b6 100644 --- a/packages/cli/src/commands/generate-client.ts +++ b/packages/cli/src/commands/generate-client.ts @@ -1,4 +1,8 @@ -import { type GenerateClientConfig } from '@redocly/client-generator'; +import type { + GenerateClientConfig, + generateClient as generateClientFunction, + mergeConfig as mergeConfigFunction, +} from '@redocly/client-generator'; import { HandledError, isPlainObject, logger, pluralize } from '@redocly/openapi-core'; import { blue, gray, yellow } from 'colorette'; import { readFileSync } from 'node:fs'; @@ -73,6 +77,24 @@ function isValidServerUrl(value: string): boolean { } } +type ClientGeneratorToolkit = { + generateClient: typeof generateClientFunction; + mergeConfig: typeof mergeConfigFunction; + helperNames: readonly string[]; +}; + +type GenerationRun = { + config: CommandArgs['config']; + configDir: string; + cliFlags: GenerateClientConfig; + outputFlag: string | undefined; + toolkit: ClientGeneratorToolkit; + /** Every path this run wrote or will write — collision guard across apis and the composed entry. */ + seenOutputs: Set; + /** Every api that emits a cli module, gathered for the composed entry (client.cliOutput). */ + composable: Array<{ alias: string; cliPath: string }>; +}; + export async function handleGenerateClient({ argv, config, @@ -124,126 +146,152 @@ export async function handleGenerateClient({ config ); - const seenOutputs = new Set(); - // Every api that emits a cli module, gathered for the composed entry (client.cliOutput). - const composable: Array<{ alias: string; cliPath: string }> = []; - - for (const { path, alias } of entrypoints) { - const name = alias ?? basename(path, extname(path)); - const aliasConfig = config.forAlias(alias); - const { client, clientOutput } = aliasConfig.resolvedConfig; - const clientBlock = resolveSetup( - (isPlainObject(client) ? client : {}) as GenerateClientConfig, - configDir - ); - const clientConfig = mergeConfig(clientBlock, cliFlags); - collectGeneratorUsage(clientConfig.generators ?? [], AUTHORING_HELPER_NAMES, configDir); - - const outputPath = - argv.output !== undefined - ? resolvePath(argv.output) - : clientOutput !== undefined - ? resolvePath(configDir, clientOutput) - : resolvePath(configDir, fileNameFor(name)); - - if (!outputPath.endsWith('.ts')) { - throw new HandledError( - `\n❌ output must point at a TypeScript file (ending in .ts).\n Got: ${outputPath}\n` - ); - } - if (seenOutputs.has(outputPath)) { - throw new HandledError( - `\n❌ Two APIs write to the same path: ${outputPath}.\n Give each api a distinct \`clientOutput\`.\n` - ); - } - seenOutputs.add(outputPath); - if (clientConfig.serverUrl !== undefined && !isValidServerUrl(clientConfig.serverUrl)) { - throw new HandledError( - `\n❌ serverUrl must be an absolute URL (https://api.example.com) or a root-relative path (/v1) — set via --server-url or the \`client\` block in redocly.yaml.\n Got: ${clientConfig.serverUrl}\n` - ); - } + const run: GenerationRun = { + config, + configDir, + cliFlags, + outputFlag: argv.output, + toolkit: { generateClient, mergeConfig, helperNames: AUTHORING_HELPER_NAMES }, + seenOutputs: new Set(), + composable: [], + }; - try { - logger.info(gray(`\n Generating client for ${name}... \n`)); - const result = await generateClient({ - ...clientConfig, - api: path, - output: outputPath, - config: aliasConfig, - configDir, - }); - // The emitted module decides what composes: `cli` reaches a run as a built-in - // name, a path to an ejected copy, or another generator's prerequisite. - const cliModule = result.files.find((file) => file.path.endsWith('.cli.ts')); - if (cliModule !== undefined) { - const importExt = clientConfig.importExt ?? 'js'; - composable.push({ - alias: name, - cliPath: cliModule.path.replace(/\.ts$/, importExt === 'ts' ? '.ts' : '.js'), - }); - } - // Sibling modules (`.cli.ts`, `.zod.ts`, …) count too: the composed entry is - // written after this loop and must not land on any of them. - for (const file of result.files) { - seenOutputs.add(file.path); - } - const fileCount = `${result.files.length} ${pluralize('file', result.files.length)}`; - const summary = `Client successfully generated: ${fileCount} (${ - result.bytes - } bytes) at ${yellow(result.outputPath)}.`; - logger.info('\n' + blue(summary) + '\n'); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - generateClientTelemetry.generate_client_error_category = - categorizeGenerateClientError(message); - throw new HandledError(`\n❌ Failed to generate client for ${name}.\n ${message}\n`); - } + for (const entry of entrypoints) { + await generateApiClient(entry, run); } - // The composed entry: one binary over every api that emitted a cli module, each behind - // its alias as a namespace. Top-level `client.cliOutput` only — a per-api block composes - // nothing — and only for the run-everything form, where all the modules exist. + // Top-level `client.cliOutput` only — a per-api block composes nothing — and only for + // the run-everything form, where all the modules exist. const topLevelClient = ( isPlainObject(config.resolvedConfig.client) ? config.resolvedConfig.client : {} ) as GenerateClientConfig; - if (topLevelClient.cliOutput !== undefined && argv.api === undefined && composable.length > 0) { - const { renderComposedCliEntry } = await import('@redocly/client-generator/generate'); - const entryPath = resolvePath(configDir, topLevelClient.cliOutput); - if (!entryPath.endsWith('.ts')) { - throw new HandledError( - `\n❌ client.cliOutput must point at a TypeScript file (ending in .ts).\n Got: ${entryPath}\n` - ); + if ( + topLevelClient.cliOutput !== undefined && + argv.api === undefined && + run.composable.length > 0 + ) { + await writeComposedCliEntry(topLevelClient.cliOutput, topLevelClient.binName, run); + } +} + +async function generateApiClient( + entry: { path: string; alias?: string }, + { config, configDir, cliFlags, outputFlag, toolkit, seenOutputs, composable }: GenerationRun +): Promise { + const { path, alias } = entry; + const name = alias ?? basename(path, extname(path)); + const aliasConfig = config.forAlias(alias); + const { client, clientOutput } = aliasConfig.resolvedConfig; + const clientBlock = resolveSetup( + (isPlainObject(client) ? client : {}) as GenerateClientConfig, + configDir + ); + const clientConfig = toolkit.mergeConfig(clientBlock, cliFlags); + collectGeneratorUsage(clientConfig.generators ?? [], toolkit.helperNames, configDir); + + const outputPath = + outputFlag !== undefined + ? resolvePath(outputFlag) + : clientOutput !== undefined + ? resolvePath(configDir, clientOutput) + : resolvePath(configDir, fileNameFor(name)); + + if (!outputPath.endsWith('.ts')) { + throw new HandledError( + `\n❌ output must point at a TypeScript file (ending in .ts).\n Got: ${outputPath}\n` + ); + } + if (seenOutputs.has(outputPath)) { + throw new HandledError( + `\n❌ Two APIs write to the same path: ${outputPath}.\n Give each api a distinct \`clientOutput\`.\n` + ); + } + seenOutputs.add(outputPath); + if (clientConfig.serverUrl !== undefined && !isValidServerUrl(clientConfig.serverUrl)) { + throw new HandledError( + `\n❌ serverUrl must be an absolute URL (https://api.example.com) or a root-relative path (/v1) — set via --server-url or the \`client\` block in redocly.yaml.\n Got: ${clientConfig.serverUrl}\n` + ); + } + + try { + logger.info(gray(`\n Generating client for ${name}... \n`)); + const result = await toolkit.generateClient({ + ...clientConfig, + api: path, + output: outputPath, + config: aliasConfig, + configDir, + }); + // The emitted module decides what composes: `cli` reaches a run as a built-in + // name, a path to an ejected copy, or another generator's prerequisite. + const cliModule = result.files.find((file) => file.path.endsWith('.cli.ts')); + if (cliModule !== undefined) { + const importExt = clientConfig.importExt ?? 'js'; + composable.push({ + alias: name, + cliPath: cliModule.path.replace(/\.ts$/, importExt === 'ts' ? '.ts' : '.js'), + }); } - if (seenOutputs.has(entryPath)) { - throw new HandledError( - `\n❌ client.cliOutput resolves to a file this run generated: ${entryPath}.\n Give the composed entry its own path.\n` - ); + // Sibling modules (`.cli.ts`, `.zod.ts`, …) count too: the composed entry is + // written after the per-api runs and must not land on any of them. + for (const file of result.files) { + seenOutputs.add(file.path); } - const binName = - topLevelClient.binName ?? - basename(entryPath, extname(entryPath)) - .replace(/[^A-Za-z0-9]+/g, '-') - .toLowerCase(); - const content = renderComposedCliEntry( - composable.map(({ alias, cliPath }) => ({ - alias, - modulePath: `./${relative(dirname(entryPath), cliPath).split('\\').join('/')}`, - })), - binName + const fileCount = `${result.files.length} ${pluralize('file', result.files.length)}`; + const summary = `Client successfully generated: ${fileCount} (${ + result.bytes + } bytes) at ${yellow(result.outputPath)}.`; + logger.info('\n' + blue(summary) + '\n'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + generateClientTelemetry.generate_client_error_category = categorizeGenerateClientError(message); + throw new HandledError(`\n❌ Failed to generate client for ${name}.\n ${message}\n`); + } +} + +/** The composed entry: one binary over every api that emitted a cli module, each behind + * its alias as a namespace. */ +async function writeComposedCliEntry( + cliOutput: string, + configuredBinName: string | undefined, + { configDir, seenOutputs, composable }: GenerationRun +): Promise { + const { renderComposedCliEntry } = await import('@redocly/client-generator/generate'); + const entryPath = resolvePath(configDir, cliOutput); + if (!entryPath.endsWith('.ts')) { + throw new HandledError( + `\n❌ client.cliOutput must point at a TypeScript file (ending in .ts).\n Got: ${entryPath}\n` ); - await mkdir(dirname(entryPath), { recursive: true }); - await writeFile(entryPath, content, 'utf-8'); - generateClientTelemetry.generate_client_composed_apis_count = composable.length; - logger.info( - '\n' + - blue( - `Composed CLI written to ${yellow(relative(process.cwd(), entryPath))} — ${composable - .map(({ alias }) => alias) - .join(', ')} behind one \`${binName}\` binary.` - ) + - '\n' + } + if (seenOutputs.has(entryPath)) { + throw new HandledError( + `\n❌ client.cliOutput resolves to a file this run generated: ${entryPath}.\n Give the composed entry its own path.\n` ); } + const binName = + configuredBinName ?? + basename(entryPath, extname(entryPath)) + .replace(/[^A-Za-z0-9]+/g, '-') + .toLowerCase(); + const content = renderComposedCliEntry( + composable.map(({ alias, cliPath }) => ({ + alias, + modulePath: `./${relative(dirname(entryPath), cliPath).split('\\').join('/')}`, + })), + binName + ); + await mkdir(dirname(entryPath), { recursive: true }); + await writeFile(entryPath, content, 'utf-8'); + generateClientTelemetry.generate_client_composed_apis_count = composable.length; + logger.info( + '\n' + + blue( + `Composed CLI written to ${yellow(relative(process.cwd(), entryPath))} — ${composable + .map(({ alias }) => alias) + .join(', ')} behind one \`${binName}\` binary.` + ) + + '\n' + ); } /** A custom generator shared by several apis counts once, like the built-in names. */ diff --git a/packages/client-generator/src/emitters/runtime-sources.ts b/packages/client-generator/src/emitters/runtime-sources.ts index b32a195078..434539ba5f 100644 --- a/packages/client-generator/src/emitters/runtime-sources.ts +++ b/packages/client-generator/src/emitters/runtime-sources.ts @@ -25,7 +25,7 @@ export const RUNTIME_SOURCES = { 'paginate.ts': "import type { OperationArgs } from './create-client.js';\nimport type { PaginationSpec, QueryValue, RequestOptions } from './types.js';\n\n/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `params` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nexport function resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `params[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nexport async function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.params?.[spec.param];\n while (true) {\n const params = { ...args.params };\n if (cursor !== undefined) params[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, params }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `params[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.params?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call(\n { ...args, params: { ...args.params, [spec.param]: position } },\n init\n );\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nexport async function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\nexport type LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nexport function linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nexport async function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let params = args.params;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, params }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n params = { ...args.params, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nexport async function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n", 'cli.ts': - "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n binName: string;\n /** Credential variable prefix. Defaults to `binName`, constant-cased — set it when the\n * displayed name and the credential family must differ (a composed multi-API binary). */\n envPrefix?: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\nexport type CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\nexport type CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\nexport type CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\nexport type CommandSource = {\n namespace?: string;\n commands: Array;\n /** Absent = inherit the first wired source's — a root `login` shares the binary's identity. */\n wiring?: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nexport function groupSlug(group: string): string {\n return group\n .trim()\n .replace(/[^A-Za-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .toLowerCase();\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nexport function envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nexport async function runCli(sources: CommandSource[], argv: string[]): Promise;\nexport async function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // A source without wiring inherits the first wired one, so the documented root-source\n // shape `{ commands: [login] }` works: the login shares the composed binary's identity.\n const inherited = sources.find((source) => source.wiring !== undefined)?.wiring;\n const wiringOf = (source: CommandSource): CliWiring => source.wiring ?? (inherited as CliWiring);\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = wiringOf(sources[0]);\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.binName)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, wiringOf(source), argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, wiringOf(root as CommandSource), argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], binName: string): string[] {\n const lines = [`Usage: ${binName} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${binName} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n for (const line of renderHelp(\n commands,\n wiring.binName,\n wiring.schemes ?? [],\n prefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n // Redaction matches these against header VALUES — so basic auth must contribute the\n // form the wire actually carries (`Basic ${base64(user:pass)}`), not just the raw\n // password, which never appears in the encoded header.\n const basic = auth.basic as { username: string; password: string } | undefined;\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(basic ? [basic.password, btoa(`${basic.username}:${basic.password}`)] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n // An SSE method returns a lazy stream — drain the stubbed response so its fetch\n // actually runs and captures the request.\n if (command.sse) for await (const _event of result as AsyncIterable);\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", + "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n binName: string;\n /** Credential variable prefix. Defaults to `binName`, constant-cased — set it when the\n * displayed name and the credential family must differ (a composed multi-API binary). */\n envPrefix?: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\nexport type CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\nexport type CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\nexport type CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\nexport type CommandSource = {\n namespace?: string;\n commands: Array;\n /** Absent = inherit the first wired source's — a root `login` shares the binary's identity. */\n wiring?: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nexport function groupSlug(group: string): string {\n return group\n .toLowerCase()\n .split(/[^a-z0-9]+/)\n .filter(Boolean)\n .join('-');\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nexport function envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nexport async function runCli(sources: CommandSource[], argv: string[]): Promise;\nexport async function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // A source without wiring inherits the first wired one, so the documented root-source\n // shape `{ commands: [login] }` works: the login shares the composed binary's identity.\n const inherited = sources.find((source) => source.wiring !== undefined)?.wiring;\n const wiringOf = (source: CommandSource): CliWiring => source.wiring ?? (inherited as CliWiring);\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = wiringOf(sources[0]);\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.binName)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, wiringOf(source), argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, wiringOf(root as CommandSource), argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], binName: string): string[] {\n const lines = [`Usage: ${binName} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${binName} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n for (const line of renderHelp(\n commands,\n wiring.binName,\n wiring.schemes ?? [],\n prefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n // Redaction matches these against header VALUES — so basic auth must contribute the\n // form the wire actually carries (`Basic ${base64(user:pass)}`), not just the raw\n // password, which never appears in the encoded header.\n const basic = auth.basic as { username: string; password: string } | undefined;\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(basic ? [basic.password, btoa(`${basic.username}:${basic.password}`)] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n // An SSE method returns a lazy stream — drain the stubbed response so its fetch\n // actually runs and captures the request.\n if (command.sse) for await (const _event of result as AsyncIterable);\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", } as const; /** Inline-embed variants: imports dropped, `export` stripped outside the kept surface. */ @@ -55,7 +55,7 @@ export const RUNTIME_SOURCES_STRIPPED = { 'paginate.ts': "/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `params` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nfunction resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `params[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nasync function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.params?.[spec.param];\n while (true) {\n const params = { ...args.params };\n if (cursor !== undefined) params[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, params }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `params[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.params?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call(\n { ...args, params: { ...args.params, [spec.param]: position } },\n init\n );\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nasync function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\ntype LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nfunction linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nasync function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let params = args.params;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, params }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n params = { ...args.params, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nasync function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}", 'cli.ts': - "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\ntype CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\ntype CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\ntype CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\ntype CliWiring = {\n binName: string;\n /** Credential variable prefix. Defaults to `binName`, constant-cased — set it when the\n * displayed name and the credential family must differ (a composed multi-API binary). */\n envPrefix?: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\ntype CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\ntype CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\ntype CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\ntype CommandSource = {\n namespace?: string;\n commands: Array;\n /** Absent = inherit the first wired source's — a root `login` shares the binary's identity. */\n wiring?: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nfunction groupSlug(group: string): string {\n return group\n .trim()\n .replace(/[^A-Za-z0-9]+/g, '-')\n .replace(/^-+|-+$/g, '')\n .toLowerCase();\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nfunction parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nfunction envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nasync function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nasync function runCli(sources: CommandSource[], argv: string[]): Promise;\nasync function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // A source without wiring inherits the first wired one, so the documented root-source\n // shape `{ commands: [login] }` works: the login shares the composed binary's identity.\n const inherited = sources.find((source) => source.wiring !== undefined)?.wiring;\n const wiringOf = (source: CommandSource): CliWiring => source.wiring ?? (inherited as CliWiring);\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = wiringOf(sources[0]);\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.binName)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, wiringOf(source), argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, wiringOf(root as CommandSource), argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], binName: string): string[] {\n const lines = [`Usage: ${binName} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${binName} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n for (const line of renderHelp(\n commands,\n wiring.binName,\n wiring.schemes ?? [],\n prefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n // Redaction matches these against header VALUES — so basic auth must contribute the\n // form the wire actually carries (`Basic ${base64(user:pass)}`), not just the raw\n // password, which never appears in the encoded header.\n const basic = auth.basic as { username: string; password: string } | undefined;\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(basic ? [basic.password, btoa(`${basic.username}:${basic.password}`)] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n // An SSE method returns a lazy stream — drain the stubbed response so its fetch\n // actually runs and captures the request.\n if (command.sse) for await (const _event of result as AsyncIterable);\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}", + "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\ntype CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\ntype CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\ntype CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\ntype CliWiring = {\n binName: string;\n /** Credential variable prefix. Defaults to `binName`, constant-cased — set it when the\n * displayed name and the credential family must differ (a composed multi-API binary). */\n envPrefix?: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\ntype CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\ntype CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\ntype CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\ntype CommandSource = {\n namespace?: string;\n commands: Array;\n /** Absent = inherit the first wired source's — a root `login` shares the binary's identity. */\n wiring?: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nfunction groupSlug(group: string): string {\n return group\n .toLowerCase()\n .split(/[^a-z0-9]+/)\n .filter(Boolean)\n .join('-');\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nfunction parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nfunction envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nasync function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nasync function runCli(sources: CommandSource[], argv: string[]): Promise;\nasync function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // A source without wiring inherits the first wired one, so the documented root-source\n // shape `{ commands: [login] }` works: the login shares the composed binary's identity.\n const inherited = sources.find((source) => source.wiring !== undefined)?.wiring;\n const wiringOf = (source: CommandSource): CliWiring => source.wiring ?? (inherited as CliWiring);\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = wiringOf(sources[0]);\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.binName)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, wiringOf(source), argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, wiringOf(root as CommandSource), argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], binName: string): string[] {\n const lines = [`Usage: ${binName} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${binName} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n for (const line of renderHelp(\n commands,\n wiring.binName,\n wiring.schemes ?? [],\n prefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n // Redaction matches these against header VALUES — so basic auth must contribute the\n // form the wire actually carries (`Basic ${base64(user:pass)}`), not just the raw\n // password, which never appears in the encoded header.\n const basic = auth.basic as { username: string; password: string } | undefined;\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(basic ? [basic.password, btoa(`${basic.username}:${basic.password}`)] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n // An SSE method returns a lazy stream — drain the stubbed response so its fetch\n // actually runs and captures the request.\n if (command.sse) for await (const _event of result as AsyncIterable);\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}", } as const; export type RuntimeModuleName = keyof typeof RUNTIME_SOURCES; diff --git a/packages/client-generator/src/generators/cli/index.ts b/packages/client-generator/src/generators/cli/index.ts index 8d2c914cdc..07dc2f9afe 100644 --- a/packages/client-generator/src/generators/cli/index.ts +++ b/packages/client-generator/src/generators/cli/index.ts @@ -16,9 +16,10 @@ import type { CodeSample, Generator, SampleContext } from '../types.js'; function commandName(stem: string): string { return ( stem - .replace(/[^A-Za-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .toLowerCase() || 'client' + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean) + .join('-') || 'client' ); } diff --git a/packages/client-generator/src/runtime/cli.ts b/packages/client-generator/src/runtime/cli.ts index 3e823d4f26..88f2a228d1 100644 --- a/packages/client-generator/src/runtime/cli.ts +++ b/packages/client-generator/src/runtime/cli.ts @@ -167,10 +167,10 @@ const GLOBAL_FLAGS: Record */ export function groupSlug(group: string): string { return group - .trim() - .replace(/[^A-Za-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .toLowerCase(); + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean) + .join('-'); } /** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */ From 266bf676d5fd6af44d04333bcb55558383610168 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Thu, 13 Aug 2026 15:02:25 +0300 Subject: [PATCH 160/211] docs: split the multi-clause sentences in the CLI composition and eject guides --- .../@v2/guides/customize-client-generation.md | 3 ++- docs/@v2/guides/use-generated-client.md | 19 +++++++++++++------ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index df2e005b0f..49ad92509d 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -79,7 +79,8 @@ The quickest method to get a customized generator is [`redocly eject-generator `](../commands/eject-generator.md). The command copies any built-in generator into `./generators/` as an editable file that you own. An ejected generator with no changes produces byte-identical output. -In `client.generators`, the path to your copy replaces the built-in name, so `redocly generate-client` keeps working and now runs your version. +In `client.generators`, the path to your copy replaces the built-in name. +Because of this, `redocly generate-client` now runs your version. [`--update`](../commands/eject-generator.md#update-an-ejected-generator) merges later built-in versions into your copy. The eject command also writes the generator's design as an agent skill (`.claude/skills/-generator/SKILL.md`). diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index 99d7fc5ea8..646b2079ae 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -70,7 +70,9 @@ You can search for `listOrders` in your API description, in your SDK, and in you The top-level help shows every global flag under `Global flags:`: `--server-url`, `--format json|ndjson`, `--dry-run`, `--page-all`, `--output`, `--token`, and `--json`. The same section shows the environment variables that the CLI reads. -The CLI reads credentials from environment variables, with a prefix derived from the output file name in constant case (`MY_API_*` for `my-api.ts`; `binName` overrides it). +The CLI reads credentials from environment variables. +The prefix is the output file name in constant case: `MY_API_*` for `my-api.ts`. +The `binName` option overrides the prefix. For bearer auth, use `_TOKEN` (or `--token`). For basic auth, use `_USERNAME` and `_PASSWORD`. For apiKey auth, use `_API_KEY_`. @@ -110,7 +112,8 @@ This makes two things possible without changes to the generated files. **One binary for several APIs.** Set a top-level `client.cliOutput`. Then `redocly generate-client` (no api argument) emits a composed entry for every api that emits a cli module. -Each api's alias from `apis:` (`shop` and `kitchen` below) becomes its command namespace, and its credentials are read under `__*`: +Each api's alias from `apis:` becomes its command namespace (`shop` and `kitchen` below). +The CLI reads each api's credentials under `__*`: ```yaml client: @@ -127,9 +130,13 @@ npx tsx src/cafe.ts shop listOrders --limit 3 # CAFE_SHOP_TOKEN npx tsx src/cafe.ts kitchen createOrder --json @o.json # CAFE_KITCHEN_TOKEN ``` -`binName` is the name the help output prints and the prefix of the credential variables — it does not install a `cafe` executable. -To type `cafe` instead of `npx tsx src/cafe.ts`, compile the entry and point the `bin` field of `package.json` at it, as described at the end of this section. -The alias namespace exists because operationIds are only unique within one description: if two descriptions declare the same operationId, the result is two different commands. +`binName` sets the name in the help output and the prefix of the credential variables. +It does not install a `cafe` executable. +To type `cafe` instead of `npx tsx src/cafe.ts`, compile the entry and point the `bin` field of `package.json` at the compiled file. +The end of this section shows this step. +An operationId is unique only within one description. +Because of this, each command carries its api's alias as a namespace. +If two descriptions declare the same operationId, the result is two different commands. Each api keeps its own server URL, schemes, and credentials. **Commands the description doesn't have.** @@ -402,7 +409,7 @@ Strip-only mode rejects these constructs, because it would have to generate assi Credentials are **per instance**. They live in the client config (`ClientConfig.auth`). Each operation automatically sends the credentials that its `security` requires. -A description that declares no `securitySchemes` produces a client with no auth code at all. +A description that declares no `securitySchemes` produces a client with no auth code. The generator emits a setter for each `securityScheme` that the runtime can apply: | Scheme | Setter | Applied as | From 33a7d95872dc798e841874427f8838a061ed7937 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Thu, 13 Aug 2026 15:19:16 +0300 Subject: [PATCH 161/211] fix: wire eject config only where generation reads it, name the sdk-to-typescript rename in errors and the changeset, and run the benchmark with pnpm 11 --- .changeset/agent-friendly-generators.md | 2 + .github/workflows/performance.yaml | 2 +- .../commands/eject-generator.test.ts | 48 ++++++++++++++++++ packages/cli/src/commands/eject-generator.ts | 49 ++++++++++++------- .../src/generators/__tests__/resolve.test.ts | 6 +++ .../src/generators/resolve.ts | 7 +++ 6 files changed, 95 insertions(+), 19 deletions(-) diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index f10654d9fa..dc7ad85996 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -6,3 +6,5 @@ Added agent-friendly client generation: `python`, `go`, `php`, `cli`, and `cli-docs` generators beside the TypeScript ones, composable generated CLIs (custom commands, one binary over several APIs via `client.cliOutput`), a language-neutral authoring toolkit with per-generator options, and an `eject-generator` command that vendors any built-in generator — plus its design as an agent skill — into your repo. **Note**: the pagination operation extension was renamed from `x-redocly-pagination` to `x-redoclyPagination`; the old name is no longer read. + +**Note**: the TypeScript client generator is now selected as `typescript` instead of `sdk`, matching the language-named generators. Update `client.generators` lists and `--generator` flags; the old name fails with a message that points at the rename. diff --git a/.github/workflows/performance.yaml b/.github/workflows/performance.yaml index 576f4bb9b2..39c006cd3a 100644 --- a/.github/workflows/performance.yaml +++ b/.github/workflows/performance.yaml @@ -22,7 +22,7 @@ jobs: uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4.2.0 with: run_install: false - version: 10.33.0 + version: 11.21.0 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version: 24 diff --git a/packages/cli/src/__tests__/commands/eject-generator.test.ts b/packages/cli/src/__tests__/commands/eject-generator.test.ts index 3359ece358..72d4d8ac03 100644 --- a/packages/cli/src/__tests__/commands/eject-generator.test.ts +++ b/packages/cli/src/__tests__/commands/eject-generator.test.ts @@ -85,6 +85,54 @@ describe('wireConfig', () => { `); }); + it('wires despite a comment that mentions the path, and is idempotent once listed', () => { + // A mention outside the list (a comment, a longer path) is not wiring. + expect( + wire(outdent` + # was: ./generators/php.mjs + client: + generators: + - typescript + `) + ).toBe(outdent` + # was: ./generators/php.mjs + client: + generators: + - typescript + - ./generators/php.mjs + `); + // A real list entry is — the file stays unchanged. + const wired = outdent` + client: + generators: + - ./generators/php.mjs + `; + expect(wire(wired)).toBe(wired); + }); + + it('prints the snippet instead when an api has its own client block', () => { + // `forAlias` replaces the top-level `client` with the api's block wholesale, so + // inserting top-level keys would report "wired" while generation ignores them. + const dir = mkdtempSync(join(tmpdir(), 'redocly-wire-config-')); + const configPath = join(dir, 'redocly.yaml'); + writeFileSync( + configPath, + outdent` + apis: + cafe: + root: ./openapi.yaml + client: + argsStyle: grouped + `, + 'utf-8' + ); + try { + expect(wireConfig(configPath, 'php', './generators/php.mjs')).toBe(false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + it('appends a client block when the config has none', () => { expect( wire( diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts index 14ac53c92e..d14898d143 100644 --- a/packages/cli/src/commands/eject-generator.ts +++ b/packages/cli/src/commands/eject-generator.ts @@ -1,4 +1,4 @@ -import { HandledError, logger } from '@redocly/openapi-core'; +import { HandledError, isPlainObject, logger, parseYaml } from '@redocly/openapi-core'; import { spawnSync } from 'node:child_process'; import { existsSync, @@ -301,29 +301,24 @@ function wireDependency( * leaving both would make the next run fail on a name collision, since the ejected file * declares the name it takes over. A config without a `client:` block or a `generators:` * list gets the missing keys appended — the common shape, since `typescript` is the - * default and nobody lists it. Only a list we can extend without guessing is edited in + * default and nobody lists it — unless an api carries its own `client` block, which + * replaces the top-level one. Only a list we can extend without guessing is edited in * place — a block sequence or a flow sequence — and anything else returns false, so the * caller prints the snippet instead of reshaping someone's config. */ export function wireConfig(configPath: string | undefined, name: string, entry: string): boolean { if (configPath === undefined || !existsSync(configPath)) return false; const source = readFileSync(configPath, 'utf-8'); - if (source.includes(entry)) return true; + const isItem = (value: string) => (item: string) => + item === value || item === `'${value}'` || item === `"${value}"`; + const isNameEntry = isItem(name); + const isPathEntry = isItem(entry); const lines = source.split('\n'); const clientLine = lines.findIndex((line) => /^client:\s*$/.test(line)); - if (clientLine === -1) { - if (/^client:/m.test(source)) return false; // `client: {...}` or similar — not a shape we edit - const separator = source === '' || source.endsWith('\n') ? '' : '\n'; - writeFileSync( - configPath, - `${source}${separator}client:\n generators:\n - ${entry}\n`, - 'utf-8' - ); - return true; - } - let generatorsLine = lines.findIndex( - (line, index) => index > clientLine && /^\s+generators:/.test(line) - ); + let generatorsLine = + clientLine === -1 + ? -1 + : lines.findIndex((line, index) => index > clientLine && /^\s+generators:/.test(line)); // A `generators:` beyond a dedented line belongs to another block — the `client:` // block itself has none. if ( @@ -333,12 +328,28 @@ export function wireConfig(configPath: string | undefined, name: string, entry: generatorsLine = -1; } if (generatorsLine === -1) { + // Inserting the missing keys only helps when the top-level `client` block is the one + // generation reads. An api's own `client` block replaces it wholesale (`forAlias`), + // so with one present the caller prints the snippet and the user picks the block. + const parsed = parseYaml(source); + const apis = isPlainObject(parsed) && isPlainObject(parsed.apis) ? parsed.apis : {}; + if (Object.values(apis).some((api) => isPlainObject(api) && isPlainObject(api.client))) { + return false; + } + if (clientLine === -1) { + if (/^client:/m.test(source)) return false; // `client: {...}` or similar — not a shape we edit + const separator = source === '' || source.endsWith('\n') ? '' : '\n'; + writeFileSync( + configPath, + `${source}${separator}client:\n generators:\n - ${entry}\n`, + 'utf-8' + ); + return true; + } lines.splice(clientLine + 1, 0, ' generators:', ` - ${entry}`); writeFileSync(configPath, lines.join('\n'), 'utf-8'); return true; } - const isNameEntry = (item: string) => - item === name || item === `'${name}'` || item === `"${name}"`; const flow = lines[generatorsLine].match(/^(\s+generators:\s*\[)(.*)\]\s*$/); if (flow !== null) { @@ -346,6 +357,7 @@ export function wireConfig(configPath: string | undefined, name: string, entry: .split(',') .map((item) => item.trim()) .filter((item) => item !== ''); + if (items.some(isPathEntry)) return true; const nameEntry = items.findIndex(isNameEntry); if (nameEntry === -1) items.push(entry); else items[nameEntry] = entry; @@ -359,6 +371,7 @@ export function wireConfig(configPath: string | undefined, name: string, entry: for (let index = generatorsLine + 1; index < lines.length; index++) { const item = lines[index].match(/^(\s+)- (.*?)\s*$/); if (item === null) break; + if (isPathEntry(item[2])) return true; if (isNameEntry(item[2])) { lines[index] = `${item[1]}- ${entry}`; writeFileSync(configPath, lines.join('\n'), 'utf-8'); diff --git a/packages/client-generator/src/generators/__tests__/resolve.test.ts b/packages/client-generator/src/generators/__tests__/resolve.test.ts index bc037552fd..3312fd9429 100644 --- a/packages/client-generator/src/generators/__tests__/resolve.test.ts +++ b/packages/client-generator/src/generators/__tests__/resolve.test.ts @@ -17,6 +17,12 @@ describe('resolveGenerators', () => { expect(registry.has('zod')).toBe(true); }); + it('names the rename for the retired "sdk" entry instead of importing it as a package', async () => { + await expect(resolveGenerators(['sdk'])).rejects.toThrow( + 'The "sdk" generator is now named "typescript"' + ); + }); + it("keeps a registered generator's declared options schema", async () => { const custom: CustomGenerator = { name: 'route-map', diff --git a/packages/client-generator/src/generators/resolve.ts b/packages/client-generator/src/generators/resolve.ts index 2ee13f5e43..36161b139e 100644 --- a/packages/client-generator/src/generators/resolve.ts +++ b/packages/client-generator/src/generators/resolve.ts @@ -87,6 +87,13 @@ async function loadEntry( registry.set(entry, { ...compatibility, ...(await load()) }); return entry; } + // Without this, the old name falls through to `import('sdk')` and fails with a + // module-load error that hides the rename. + if (entry === 'sdk') { + throw new NotSupportedError( + 'The "sdk" generator is now named "typescript". Update the `generators` list or the --generator flag.' + ); + } const custom = await importGenerator(entry, configDir ?? process.cwd()); register(registry, custom); return custom.name; From 7b2fcf8d8efa3239b9d9ae2e286c7d4986286c81 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Thu, 13 Aug 2026 15:28:42 +0300 Subject: [PATCH 162/211] fix: keep wireConfig under the complexity limit and read generator lists through comments --- .../commands/eject-generator.test.ts | 27 ++++++++ packages/cli/src/commands/eject-generator.ts | 67 ++++++++++++------- 2 files changed, 70 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/__tests__/commands/eject-generator.test.ts b/packages/cli/src/__tests__/commands/eject-generator.test.ts index 72d4d8ac03..d8f186d222 100644 --- a/packages/cli/src/__tests__/commands/eject-generator.test.ts +++ b/packages/cli/src/__tests__/commands/eject-generator.test.ts @@ -110,6 +110,33 @@ describe('wireConfig', () => { expect(wire(wired)).toBe(wired); }); + it('reads through comments in the list: inline ones survive a replace, entries below comment lines count', () => { + expect( + wire(outdent` + client: + generators: + # our copies: + - php # ours + - typescript + `) + ).toBe(outdent` + client: + generators: + # our copies: + - ./generators/php.mjs # ours + - typescript + `); + // An already-wired entry behind a comment line is found, not duplicated. + const wired = outdent` + client: + generators: + - typescript + # ejected: + - ./generators/php.mjs + `; + expect(wire(wired)).toBe(wired); + }); + it('prints the snippet instead when an api has its own client block', () => { // `forAlias` replaces the top-level `client` with the api's block wholesale, so // inserting top-level keys would report "wired" while generation ignores them. diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts index d14898d143..273c9f866a 100644 --- a/packages/cli/src/commands/eject-generator.ts +++ b/packages/cli/src/commands/eject-generator.ts @@ -306,6 +306,40 @@ function wireDependency( * place — a block sequence or a flow sequence — and anything else returns false, so the * caller prints the snippet instead of reshaping someone's config. */ +/** + * The config has no top-level `generators:` list — add one where generation will read + * it, or decline. Inserting the missing keys only helps when the top-level `client` + * block is the one generation reads: an api's own `client` block replaces it wholesale + * (`forAlias`), so with one present the caller prints the snippet and the user picks + * the block. + */ +function insertGeneratorsList( + configPath: string, + source: string, + lines: string[], + clientLine: number, + entry: string +): boolean { + const parsed = parseYaml(source); + const apis = isPlainObject(parsed) && isPlainObject(parsed.apis) ? parsed.apis : {}; + if (Object.values(apis).some((api) => isPlainObject(api) && isPlainObject(api.client))) { + return false; + } + if (clientLine === -1) { + if (/^client:/m.test(source)) return false; // `client: {...}` or similar — not a shape we edit + const separator = source === '' || source.endsWith('\n') ? '' : '\n'; + writeFileSync( + configPath, + `${source}${separator}client:\n generators:\n - ${entry}\n`, + 'utf-8' + ); + return true; + } + lines.splice(clientLine + 1, 0, ' generators:', ` - ${entry}`); + writeFileSync(configPath, lines.join('\n'), 'utf-8'); + return true; +} + export function wireConfig(configPath: string | undefined, name: string, entry: string): boolean { if (configPath === undefined || !existsSync(configPath)) return false; const source = readFileSync(configPath, 'utf-8'); @@ -328,27 +362,7 @@ export function wireConfig(configPath: string | undefined, name: string, entry: generatorsLine = -1; } if (generatorsLine === -1) { - // Inserting the missing keys only helps when the top-level `client` block is the one - // generation reads. An api's own `client` block replaces it wholesale (`forAlias`), - // so with one present the caller prints the snippet and the user picks the block. - const parsed = parseYaml(source); - const apis = isPlainObject(parsed) && isPlainObject(parsed.apis) ? parsed.apis : {}; - if (Object.values(apis).some((api) => isPlainObject(api) && isPlainObject(api.client))) { - return false; - } - if (clientLine === -1) { - if (/^client:/m.test(source)) return false; // `client: {...}` or similar — not a shape we edit - const separator = source === '' || source.endsWith('\n') ? '' : '\n'; - writeFileSync( - configPath, - `${source}${separator}client:\n generators:\n - ${entry}\n`, - 'utf-8' - ); - return true; - } - lines.splice(clientLine + 1, 0, ' generators:', ` - ${entry}`); - writeFileSync(configPath, lines.join('\n'), 'utf-8'); - return true; + return insertGeneratorsList(configPath, source, lines, clientLine, entry); } const flow = lines[generatorsLine].match(/^(\s+generators:\s*\[)(.*)\]\s*$/); @@ -369,11 +383,16 @@ export function wireConfig(configPath: string | undefined, name: string, entry: let lastItem = generatorsLine; let itemIndent = `${lines[generatorsLine].match(/^\s+/)![0]} `; for (let index = generatorsLine + 1; index < lines.length; index++) { + // Blank and comment lines are legal inside a block sequence — the list continues. + if (/^\s*(#|$)/.test(lines[index])) continue; const item = lines[index].match(/^(\s+)- (.*?)\s*$/); if (item === null) break; - if (isPathEntry(item[2])) return true; - if (isNameEntry(item[2])) { - lines[index] = `${item[1]}- ${entry}`; + // A trailing comment is not part of the value (`- php # ours`), and it survives a replace. + const comment = item[2].match(/\s+#.*$/)?.[0] ?? ''; + const value = comment === '' ? item[2] : item[2].slice(0, -comment.length); + if (isPathEntry(value)) return true; + if (isNameEntry(value)) { + lines[index] = `${item[1]}- ${entry}${comment}`; writeFileSync(configPath, lines.join('\n'), 'utf-8'); return true; } From abada70fff5ea79a89587bc4ecd11111c0938b60 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Thu, 13 Aug 2026 16:10:53 +0300 Subject: [PATCH 163/211] chore: strip narration comments from eject-generator --- packages/cli/src/commands/eject-generator.ts | 49 +++++++------------- 1 file changed, 16 insertions(+), 33 deletions(-) diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts index 273c9f866a..f5cd338b9f 100644 --- a/packages/cli/src/commands/eject-generator.ts +++ b/packages/cli/src/commands/eject-generator.ts @@ -250,7 +250,6 @@ function ejectedIn(dir: string): string[] { * the requirement part of the project so a fresh clone or CI gets it. With `refresh` (the * `--update` path), a recorded range that no longer covers `version` is moved to * `^version` wherever the project keeps it — the merged file targets the new toolkit. - * Returns what happened. */ function wireDependency( packages: Record, @@ -296,22 +295,9 @@ function wireDependency( } /** - * Add the ejected file to `client.generators` in the configuration file, editing the text - * so comments and formatting survive. A bare `` entry is replaced rather than kept — - * leaving both would make the next run fail on a name collision, since the ejected file - * declares the name it takes over. A config without a `client:` block or a `generators:` - * list gets the missing keys appended — the common shape, since `typescript` is the - * default and nobody lists it — unless an api carries its own `client` block, which - * replaces the top-level one. Only a list we can extend without guessing is edited in - * place — a block sequence or a flow sequence — and anything else returns false, so the - * caller prints the snippet instead of reshaping someone's config. - */ -/** - * The config has no top-level `generators:` list — add one where generation will read - * it, or decline. Inserting the missing keys only helps when the top-level `client` - * block is the one generation reads: an api's own `client` block replaces it wholesale - * (`forAlias`), so with one present the caller prints the snippet and the user picks - * the block. + * The config has no top-level `generators:` list — add one, unless an api's own `client` + * block would replace it wholesale (`forAlias`); then the caller prints the snippet and + * the user picks the block. */ function insertGeneratorsList( configPath: string, @@ -340,6 +326,12 @@ function insertGeneratorsList( return true; } +/** + * Point `client.generators` at the ejected file, editing the text so comments and + * formatting survive. A bare `` entry is replaced — keeping both would collide on + * the name the ejected file takes over. A shape this can't extend without guessing + * returns false, and the caller prints the snippet instead of reshaping someone's config. + */ export function wireConfig(configPath: string | undefined, name: string, entry: string): boolean { if (configPath === undefined || !existsSync(configPath)) return false; const source = readFileSync(configPath, 'utf-8'); @@ -353,8 +345,7 @@ export function wireConfig(configPath: string | undefined, name: string, entry: clientLine === -1 ? -1 : lines.findIndex((line, index) => index > clientLine && /^\s+generators:/.test(line)); - // A `generators:` beyond a dedented line belongs to another block — the `client:` - // block itself has none. + // A `generators:` beyond a dedented line belongs to another block. if ( generatorsLine !== -1 && lines.slice(clientLine + 1, generatorsLine).some((line) => /^\S/.test(line)) @@ -383,11 +374,9 @@ export function wireConfig(configPath: string | undefined, name: string, entry: let lastItem = generatorsLine; let itemIndent = `${lines[generatorsLine].match(/^\s+/)![0]} `; for (let index = generatorsLine + 1; index < lines.length; index++) { - // Blank and comment lines are legal inside a block sequence — the list continues. if (/^\s*(#|$)/.test(lines[index])) continue; const item = lines[index].match(/^(\s+)- (.*?)\s*$/); if (item === null) break; - // A trailing comment is not part of the value (`- php # ours`), and it survives a replace. const comment = item[2].match(/\s+#.*$/)?.[0] ?? ''; const value = comment === '' ? item[2] : item[2].slice(0, -comment.length); if (isPathEntry(value)) return true; @@ -431,13 +420,11 @@ function updateEjectedGenerator({ `\n❌ Nothing to update: ${printedTarget} does not exist. Eject first.\n` ); } - // Ejects before the base moved to the registry left a snapshot behind; it still works - // as the base, which keeps `--update` offline for anyone mid-migration. + // Legacy ejects left a `.pristine` snapshot behind; it still works as the merge base. const legacyBase = join(dir, '.pristine', `${name}.mjs`); const customized = readFileSync(target, 'utf-8'); const from = recordedVersion(customized); - // Version distance behind the conflict count — both OUR versions. The header is - // user-editable text, so it's recorded only when it parses as a semver version. + // The header is user-editable text, so the version is recorded only when it parses. if (from !== undefined && semver.valid(from) !== null) { ejectGeneratorTelemetry.eject_generator_from_version = from; } @@ -513,8 +500,7 @@ export const handleEjectGenerator = async ({ if (EJECTABLE.has(name) || FRAMEWORK_VARIANTS.has(name)) { ejectGeneratorTelemetry.eject_generator_name = name; } - // Every path that finishes overwrites this, so it survives only when something we did - // not account for throws — an unreadable asset, a failed write, a missing directory. + // Every path that finishes overwrites this, so it survives only an unaccounted throw. ejectGeneratorTelemetry.eject_generator_outcome = 'unexpected-error'; const framework = FRAMEWORK_VARIANTS.get(name); if (framework !== undefined) { @@ -560,10 +546,8 @@ export const handleEjectGenerator = async ({ const authoringSkill = dropSkill('client-generators', assetsDir); const designSkill = dropSkill(`${name}-generator`, assetsDir); dropPointer(dir, ejectedIn(dir)); - // Config-file generator entries resolve against the config's directory, so the wired - // path is relative to it — real paths on both sides, so a symlinked location (like - // macOS /var/folders) doesn't skew the walk. The cwd anchors only the snippet for a - // config yet to exist. + // Config-file entries resolve against the config's directory, so the wired path is + // relative to it — real paths on both sides, so a symlink doesn't skew the walk. const configEntry = `./${relative( config.configPath === undefined ? process.cwd() : realpathSync(dirname(config.configPath)), realpathSync(target) @@ -571,8 +555,7 @@ export const handleEjectGenerator = async ({ .split('\\') .join('/')}`; const dependency = wireDependency({ [TOOLKIT_PACKAGE]: toolkitVersion }); - // A bundled TypeScript generator also imports `logger`/`isPlainObject` from core, which - // the toolkit depends on — worth saying out loud for a package manager that doesn't hoist. + // A bundled TypeScript generator also imports from core; without hoisting it must be explicit. const needsCore = asset.includes(`from "${CORE_PACKAGE}"`); const wired = wireConfig(config.configPath, name, configEntry); logger.info( From 248190f9e4bc2445491f3849c90d43b1c87ab06d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20=C5=81=C4=99kawa?= <164185257+JLekawa@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:34:24 +0200 Subject: [PATCH 164/211] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jacek Łękawa <164185257+JLekawa@users.noreply.github.com> --- docs/@v2/commands/eject-generator.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/@v2/commands/eject-generator.md b/docs/@v2/commands/eject-generator.md index dc42ef1123..1956643f19 100644 --- a/docs/@v2/commands/eject-generator.md +++ b/docs/@v2/commands/eject-generator.md @@ -4,7 +4,8 @@ The `eject-generator` command copies a built-in client generator into your repository as an editable file. You own the ejected generator and can customize it. -The _generated_ client stays generated and reproducible, so do not edit it manually. +The generated client stays generated and reproducible. +Do not edit it manually. You or your agent edit the generator, and the `redocly generate-client` command rebuilds the client. When the spec changes later, the command regenerates the client and keeps your customization. @@ -27,7 +28,7 @@ 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 files. Default: `./generators`. | | `--update` | boolean | Do a three-way merge of the current built-in version into your customized copy. The command marks conflicts with standard markers. | | `--force` | boolean | Overwrite an existing ejected file and discard the local edits. | @@ -36,9 +37,9 @@ redocly eject-generator php --force The eject operation writes two files: - `/.mjs` is the generator itself, as a plain ESM file that you own. - It contains everything that it needs to run standalone. + 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 as we wrote it. + You get its source exactly as it was written. A TypeScript generator is a thin entry point that uses shared emitters, so you get it bundled together with those emitters. The bundle is not minified, and a comment marks each source module. @@ -53,11 +54,11 @@ The eject operation writes two files: The first eject also writes `.claude/skills/client-generators/SKILL.md`, the shared authoring guide. The guide describes the generator contract, the API model, and the helper library. -You can edit the skills, like the generator. +You can edit the skills, in the same way as the generator. The `--update` option does a three-way merge of your skill edits with the newer version. -A fresh eject or `--force` writes the skills as we ship them. +A fresh eject or `--force` writes the skills as Redocly ships them. -The command also writes a short pointer to the skills into `/AGENTS.md`, beside the code. +In addition to the code, the command also writes a short pointer to the skills into `/AGENTS.md`. This pointer explains the directory to a reader who has no context. The command keeps everything that you add outside the markers in that file. @@ -72,7 +73,7 @@ client: - ./generators/python.mjs ``` -If you do not modify the ejected generator, its output is byte-identical to the output of the built-in generator. +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. ## Update an ejected generator @@ -90,4 +91,4 @@ An ejected generator continues to operate across CLI upgrades if the authoring c The contract follows the `@redocly/client-generator` version. A breaking change increases the major version (the minor version, while the package is `0.x`). A generator ejected from an incompatible version fails before it runs. -The error shows the version that the generator expects, the version that you have, and the `--update` command that aligns them. +The error displays the version that the generator expects, the version that you have, and the `--update` command that aligns them. From eaf126eaac42b0e71f374057dc45ea1b36b70d4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20=C5=81=C4=99kawa?= <164185257+JLekawa@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:34:52 +0200 Subject: [PATCH 165/211] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jacek Łękawa <164185257+JLekawa@users.noreply.github.com> --- .changeset/agent-friendly-generators.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index dc7ad85996..e5329cec07 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -3,8 +3,15 @@ '@redocly/cli': minor --- -Added agent-friendly client generation: `python`, `go`, `php`, `cli`, and `cli-docs` generators beside the TypeScript ones, composable generated CLIs (custom commands, one binary over several APIs via `client.cliOutput`), a language-neutral authoring toolkit with per-generator options, and an `eject-generator` command that vendors any built-in generator — plus its design as an agent skill — into your repo. +Added agent-friendly client generation: `python`, `go`, `php`, `cli`, and `cli-docs` generators in addition to TypeScript generators. -**Note**: the pagination operation extension was renamed from `x-redocly-pagination` to `x-redoclyPagination`; the old name is no longer read. +Added composable generated CLIs (custom commands, one binary over several APIs via `client.cliOutput`). + +Added language-neutral authoring toolkit with per-generator options. + +Added an `eject-generator` command that vendors any built-in generator, with its design as an agent skill, into your repo. + +Renamed pagination operation extension from `x-redocly-pagination` to `x-redoclyPagination`. +The previous name is no longer read. **Note**: the TypeScript client generator is now selected as `typescript` instead of `sdk`, matching the language-named generators. Update `client.generators` lists and `--generator` flags; the old name fails with a message that points at the rename. From d6c63e8a8f93322eb53380cb200b81378185c28c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20=C5=81=C4=99kawa?= <164185257+JLekawa@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:35:47 +0200 Subject: [PATCH 166/211] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jacek Łękawa <164185257+JLekawa@users.noreply.github.com> --- docs/@v2/commands/generate-client.md | 37 +++++++++++++++++----------- 1 file changed, 23 insertions(+), 14 deletions(-) diff --git a/docs/@v2/commands/generate-client.md b/docs/@v2/commands/generate-client.md index 7e6550bff1..6c4b76b655 100644 --- a/docs/@v2/commands/generate-client.md +++ b/docs/@v2/commands/generate-client.md @@ -45,22 +45,22 @@ 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 value is `single`. | -| `--runtime` | string | The location of the client engine. See [Choose a runtime](#choose-a-runtime).
**Possible values:** `inline`, `package`. Default value is `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 value is `js`. | +| `--output-mode` | string | The file layout. See [Choose an output mode](#choose-an-output-mode).
**Possible values:** `single`, `split`. Default: `single`. | +| `--runtime` | string | The location of the client engine. See [Choose a runtime](#choose-a-runtime).
**Possible values:** `inline`, `package`. Default: `inline`. | +| `--import-ext` | string | The extension in the generated relative imports. See [Run with Node directly](../guides/use-generated-client.md#run-with-node-directly).
**Possible values:** `js` (the tsc/bundler convention), `ts` (for Node's built-in type stripping). Default: `js`. | | `--generator` | [string] | The generator to run: a built-in name, or the path or package of a custom generator. Repeat the flag to run more than one generator. Default value is `typescript`. See [Generators](../guides/use-generated-client.md#generators) for the full list. | -| `--args-style` | string | Sets how you pass inputs to operations. See [Argument style](../guides/use-generated-client.md#argument-style).
**Possible values:** `flat`, `grouped`. Default value is `flat`. | -| `--error-mode` | string | Sets how operations report HTTP errors. See [Error handling](../guides/use-generated-client.md#error-handling).
**Possible values:** `throw`, `result`. Default value is `throw`. | -| `--date-type` | string | The type of the `date`/`date-time` fields. If you use `Date`, also use the `transformers` generator.
**Possible values:** `string`, `Date`. Default value is `string`. | -| `--mock-data` | string | The data mode for the `mock` generator.
**Possible values:** `static` (deterministic literals), `faker` (`@faker-js/faker` calls). Default value is `static`. | +| `--args-style` | string | Sets how you pass inputs to operations. See [Argument style](../guides/use-generated-client.md#argument-style).
**Possible values:** `flat`, `grouped`. Default: `flat`. | +| `--error-mode` | string | Sets how operations report HTTP errors. See [Error handling](../guides/use-generated-client.md#error-handling).
**Possible values:** `throw`, `result`. Default: `throw`. | +| `--date-type` | string | The type of the `date`/`date-time` fields. If you use `Date`, also use the `transformers` generator.
**Possible values:** `string`, `Date`. Default: `string`. | +| `--mock-data` | string | The data mode for the `mock` generator.
**Possible values:** `static` (deterministic literals), `faker` (`@faker-js/faker` calls). Default: `static`. | | `--mock-seed` | number | The seed for `faker`-mode mocks. Use it to get reproducible data. The command ignores it in `static` mode. | | `--server-url` | string | Overrides the default server URL in the client. The option accepts an absolute URL (`https://api.example.com`) or a relative URL (`/v1`). Defaults to `servers[0].url`. The app can also change the server URL at runtime with `createClient({ serverUrl })` or `configure({ serverUrl })`. See [Authentication](../guides/use-generated-client.md#authentication) in the usage guide. | | `--setup` | string | The path to a publisher setup module that the command includes in the client. Use it to pre-configure defaults, for example the server URL, retries, headers, and middleware. A published SDK then contains these defaults. See [Publisher defaults](../guides/customize-client-generation.md#publisher-defaults). | | `--bin-name` | string | The command name that the `cli` generator prints in the help output. The generator also uses it to derive the names of the credential environment variables. Defaults to the output file name (without extension) with non-word characters converted to `-`. | | `--go-package` | string | The package clause in the output of the `go` generator. It must be a valid Go package name (lowercase letters, digits, and `_`; it must not start with a digit or be a keyword). Default value is `client`. | | `--config` | string | Specify the path to the [configuration file](#generate-from-the-configuration-file). | -| `--help` | boolean | Show help. | -| `--version` | boolean | Show version number. | +| `--help` | boolean | Display help. | +| `--version` | boolean | Display version number. | ## Examples @@ -101,8 +101,11 @@ redocly generate-client openapi.yaml --output dist/client.ts The `--output-mode` flag controls how the command splits the client into files: -- `single` (default): the command writes one file. The file is self-contained with the default `inline` runtime. -- `split`: the command writes two files. It puts the schema types and the type guards in a sibling file, `.schemas.ts`. The entry file re-exports them. Because of this, your imports are the same as in `single`. +- `single` (default): the command writes one file. + The file is self-contained with the default `inline` runtime. +- `split`: the command writes two files. + It puts the schema types and the type guards in a sibling file, `.schemas.ts`. The entry file re-exports them. + Because of this, your imports are the same as in `single`. ```bash redocly generate-client openapi.yaml -o src/api/client.ts --output-mode split @@ -114,8 +117,11 @@ Both modes work with both runtimes. 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. +- `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. @@ -127,4 +133,7 @@ See [Package runtime](../guides/use-generated-client.md#package-runtime) in the - [Use the generated client](../guides/use-generated-client.md): the runtime API and the add-on generators. - [`client` configuration](../configuration/reference/client.md): the `redocly.yaml` `client` block. - [Lint command](./lint.md): validate your API description before you generate a client. -- [Bundle command](./bundle.md): combine a multi-file description into one input file. +- **[Use the generated client](../guides/use-generated-client.md)** - Learn how to use the client produced by the `generate-client` command +- **[`client` configuration](../configuration/reference/client.md)** - explore the settings for the `generate-client` command +- **[Lint command](./lint.md)** - validate your API description before you generate a client +- **[Bundle command](./bundle.md)** - combine a multi-file description into one input file From 1854e626f7631436fdf83de795e0f16e33ca895c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20=C5=81=C4=99kawa?= <164185257+JLekawa@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:37:53 +0200 Subject: [PATCH 167/211] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jacek Łękawa <164185257+JLekawa@users.noreply.github.com> --- packages/client-generator/README.md | 5 ++++- tests/e2e/generate-client/examples/README.md | 3 ++- .../examples/ejected-generator/README.md | 9 ++++++--- .../examples/fetch-functions/README.md | 3 +-- .../e2e/generate-client/examples/go-sdk/README.md | 9 ++++++++- tests/e2e/generate-client/examples/mock/README.md | 3 +-- .../examples/nested-facade/README.md | 6 +++++- .../e2e/generate-client/examples/php-sdk/README.md | 11 ++++++++++- .../generate-client/examples/python-sdk/README.md | 14 ++++++++++++-- .../examples/tanstack-query/README.md | 3 ++- .../examples/typescript-types-generator/README.md | 11 +++++------ 11 files changed, 56 insertions(+), 21 deletions(-) diff --git a/packages/client-generator/README.md b/packages/client-generator/README.md index d9d3e11ae0..7f878dd962 100644 --- a/packages/client-generator/README.md +++ b/packages/client-generator/README.md @@ -139,7 +139,10 @@ Authors a custom generator (`{ name, run }` plus optional `requires`/`errorModes function defineGenerator(generator: CustomGenerator): CustomGenerator; ``` -The `@redocly/client-generator/generate` entry also exports the TypeScript renderers the built-ins use (`tsType`, `tsJsdoc`, `codeLiteral`, `operationSignature`, `pascalCase`, `safeIdent`), and the package root exports the IR types plus the language-neutral toolkit, so a custom generator emits TypeScript exactly as the first-party ones do — see the [`typescript-types-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/typescript-types-generator). +The `@redocly/client-generator/generate` entry also exports the TypeScript renderers the built-ins use (`tsType`, `tsJsdoc`, `codeLiteral`, `operationSignature`, `pascalCase`, `safeIdent`). +The package root exports the IR types plus the language-neutral toolkit. +A custom generator emits TypeScript exactly as the first-party ones do. +See the [`typescript-types-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/typescript-types-generator). ### `defineClientSetup` diff --git a/tests/e2e/generate-client/examples/README.md b/tests/e2e/generate-client/examples/README.md index ba11ab2347..07968661e8 100644 --- a/tests/e2e/generate-client/examples/README.md +++ b/tests/e2e/generate-client/examples/README.md @@ -2,7 +2,8 @@ Runnable examples of clients generated by `@redocly/client-generator`. Most are Vite apps that _consume_ a client generated via the `redocly generate-client` CLI (a `redocly.yaml`); `programmatic` _generates_ one with the `generateClient(...)` API. -Most share the cafe spec in [`_shared/cafe.yaml`](./_shared/cafe.yaml); the rest carry their own. +Most share the cafe spec in [`_shared/cafe.yaml`](./_shared/cafe.yaml). +The rest carry their own. The generated client under `src/api/` is gitignored — CI regenerates every client and type-checks the consumer code against it (the `examples` job), and `zero-install-quickstart` keeps its client committed as the canonical browsable copy, drift-checked in `tests/e2e/generate-client/examples.test.ts`. diff --git a/tests/e2e/generate-client/examples/ejected-generator/README.md b/tests/e2e/generate-client/examples/ejected-generator/README.md index de3a1984db..2d52856214 100644 --- a/tests/e2e/generate-client/examples/ejected-generator/README.md +++ b/tests/e2e/generate-client/examples/ejected-generator/README.md @@ -1,7 +1,9 @@ # ejected-generator -The shadcn story for generators: `redocly eject-generator php` vendored the built-in PHP generator into `generators/php.mjs`, and this repo customized it — search the file for `CUSTOMIZATION` to see the one-line change (a platform banner in the generated header). -The _generated_ client stays machine-owned: regenerate any time and the customization is still there, because the customization lives in the generator, not in its output. +The `shadcn` story for generators: `redocly eject-generator php` vendored the built-in PHP generator into `generators/php.mjs`, and this repo customized it. +Search the file for `CUSTOMIZATION` to see the one-line change (a platform banner in the generated header). +The generated client stays machine-owned: regenerate any time while preserving customization. +The customization lives in the generator, not in its output. ```sh npm run generate @@ -14,5 +16,6 @@ Your coding agent loads them on its own: describe the change you want, and it ed `generators/AGENTS.md` is the short pointer the command leaves beside the code. `npm run update-generator` three-way-merges a newer generator version into this customized copy — clean hunks apply silently, real conflicts get standard `<<<<<<<` markers. The merge base is the version recorded in the file's own header, so there is nothing extra to commit or keep in sync. -This example started from `redocly eject-generator php`; run that in your own repo to begin. +This example started from `redocly eject-generator php`. +Run this command in your own repo to begin. The ejected file imports the authoring toolkit and the embedded runtime from `@redocly/client-generator`, so runtime fixes still arrive with plain `npm update` — no merge needed. diff --git a/tests/e2e/generate-client/examples/fetch-functions/README.md b/tests/e2e/generate-client/examples/fetch-functions/README.md index 70e0889b7e..3bd5ece5f8 100644 --- a/tests/e2e/generate-client/examples/fetch-functions/README.md +++ b/tests/e2e/generate-client/examples/fetch-functions/README.md @@ -1,7 +1,6 @@ # fetch-functions example -Generated TypeScript client (`generators: ['typescript']`), consumed as free -functions (`configure()`, `listMenuItems()`), with `ApiError` handling. +Generated TypeScript client (`generators: ['typescript']`), consumed as free functions (`configure()`, `listMenuItems()`), with `ApiError` handling. ## Run diff --git a/tests/e2e/generate-client/examples/go-sdk/README.md b/tests/e2e/generate-client/examples/go-sdk/README.md index 1c07fa5ff8..83d39cb908 100644 --- a/tests/e2e/generate-client/examples/go-sdk/README.md +++ b/tests/e2e/generate-client/examples/go-sdk/README.md @@ -1,7 +1,14 @@ # go-sdk The `go` generator emits `src/api/client.go` — a full Go SDK over the standard library (zero dependencies, Go ≥ 1.21): -structs with `json` tags, typed-const enums, a context-aware `Client` with `(T, error)` methods, auth, retries, pagination iterators (`Pages` / `Items`), SSE streaming, and multipart bodies. +- structs with `json` tags +- typed-const enums +- a context-aware `Client` with `(T, error)` methods +- auth +- retries +- pagination iterators (`Pages` / `Items`) +- SSE streaming +- multipart bodies ```sh npm run generate diff --git a/tests/e2e/generate-client/examples/mock/README.md b/tests/e2e/generate-client/examples/mock/README.md index ecbc274d26..3817d9a756 100644 --- a/tests/e2e/generate-client/examples/mock/README.md +++ b/tests/e2e/generate-client/examples/mock/README.md @@ -1,7 +1,6 @@ # mock example -Generated TypeScript client plus **MSW** mocks (`generators: ['typescript', 'mock']`), shown two ways from the -same generated `src/api/` and the same `handlers`: +Generated TypeScript client plus **MSW** mocks (`generators: ['typescript', 'mock']`), shown two ways from the same generated `src/api/` and the same `handlers`: - **Browser** (`src/main.ts`) — starts an MSW browser worker with `setupWorker` and renders the result. - **Node** (`src/node.ts`) — starts a server with `msw/node`'s `setupServer` and exports `loadMockedMenu()`. diff --git a/tests/e2e/generate-client/examples/nested-facade/README.md b/tests/e2e/generate-client/examples/nested-facade/README.md index 5540995c84..3bc7f93346 100644 --- a/tests/e2e/generate-client/examples/nested-facade/README.md +++ b/tests/e2e/generate-client/examples/nested-facade/README.md @@ -4,7 +4,11 @@ A resource-grouped call shape — `api.orders.listOrders(…)` — derived from spec's **tags** by a small [custom generator](./nested-facade-generator.mjs) (the experimental plugin API), so the nesting regenerates with the spec instead of living in a hand-maintained facade file. Everything stays fully typed: the -facade just re-exports the client's generated functions in nested objects. +A resource-grouped call shape — `api.orders.listOrders(…)` — derived from the +spec's **tags** by a small [custom generator](./nested-facade-generator.mjs) +(the experimental plugin API). +The nesting regenerates with the spec instead of living in a hand-maintained facade file. +Everything stays fully typed: the facade just re-exports the client's generated functions in nested objects. ## Run diff --git a/tests/e2e/generate-client/examples/php-sdk/README.md b/tests/e2e/generate-client/examples/php-sdk/README.md index 097ca5e2ad..2f0f65063e 100644 --- a/tests/e2e/generate-client/examples/php-sdk/README.md +++ b/tests/e2e/generate-client/examples/php-sdk/README.md @@ -1,7 +1,16 @@ # php-sdk The `php` generator emits `src/api/client.php` — a full PHP SDK over the curl extension (zero Composer dependencies, PHP ≥ 8.1): -promoted-constructor classes with `fromArray`/`toArray` hydration, native backed enums, a `Client` with typed named-argument methods, auth, retries, pagination generators (`Pages()` / `Items()`), SSE streaming, and multipart bodies. + +- promoted-constructor classes with `fromArray`/`toArray` hydration +- native backed enums +- a `Client` with typed named-argument methods +- auth +- retries +- pagination generators (`Pages()` / `Items()`) +- SSE streaming +- multipart bodies + The namespace derives from the API title (`RedoclyCafe` here). ```sh diff --git a/tests/e2e/generate-client/examples/python-sdk/README.md b/tests/e2e/generate-client/examples/python-sdk/README.md index 5ce15a1fe1..fc04ae3141 100644 --- a/tests/e2e/generate-client/examples/python-sdk/README.md +++ b/tests/e2e/generate-client/examples/python-sdk/README.md @@ -1,7 +1,17 @@ # python-sdk -The `python` generator emits `src/api/client.py` — a full Python SDK over [httpx](https://www.python-httpx.org/) (Python ≥ 3.9): -typed dataclass models, sync `Client` and async `AsyncClient`, auth, retries, pagination iterators (`_pages()` / `_items()`), SSE streaming, and multipart bodies. +The `python` generator emits `src/api/client.py`. +'It is a full Python SDK over [httpx](https://www.python-httpx.org/) (Python ≥ 3.9): + +- typed dataclass models +- sync `Client` +- async `AsyncClient` +- auth +- retries +- pagination iterators (`_pages()` / `_items()`) +- SSE streaming +- multipart bodies + No TypeScript is involved — a `python`-only selection never loads the `typescript` package. ```sh diff --git a/tests/e2e/generate-client/examples/tanstack-query/README.md b/tests/e2e/generate-client/examples/tanstack-query/README.md index 113253cb13..084fa6bf60 100644 --- a/tests/e2e/generate-client/examples/tanstack-query/README.md +++ b/tests/e2e/generate-client/examples/tanstack-query/README.md @@ -12,4 +12,5 @@ npm run generate # generate src/api (the client is gitignored) npm run dev # open the printed local URL ``` -The generated client + TanStack factories under `src/api/` are gitignored; CI regenerates them and type-checks this example. +The generated client + TanStack factories under `src/api/` are gitignored. +CI regenerates them and type-checks this example. diff --git a/tests/e2e/generate-client/examples/typescript-types-generator/README.md b/tests/e2e/generate-client/examples/typescript-types-generator/README.md index 4825f222b1..3cb5d7857b 100644 --- a/tests/e2e/generate-client/examples/typescript-types-generator/README.md +++ b/tests/e2e/generate-client/examples/typescript-types-generator/README.md @@ -1,9 +1,9 @@ # TypeScript types generator example -A custom generator that renders real TypeScript types with the -`@redocly/client-generator/generate` entry — the same type renderer the built-in generators -use, so the mapping matches the generated client exactly, instead of guessing at type text -(compare with the plain string-building [`custom-generator`](../custom-generator) example). +A custom generator that renders real TypeScript types with the `@redocly/client-generator/generate` entry. +This is the same type renderer the built-in generators use. +The mapping matches the generated client exactly, instead of guessing at type text. +Compare with the plain string-building [`custom-generator`](../custom-generator) example. - [`response-map-generator.mjs`](./response-map-generator.mjs) — the generator. For every operation with a JSON success response it derives the response body's TypeScript type @@ -31,8 +31,7 @@ runtime-only. The `/generate` entry holds everything that runs at **generation time** — it loads the TypeScript compiler and `@redocly/openapi-core`, which an app must never pull in: -- the text toolkit used here (`tsType`, `tsJsdoc`, `codeLiteral`, `operationSignature`, - `pascalCase`, …), +- the text toolkit used here (`tsType`, `tsJsdoc`, `codeLiteral`, `operationSignature`, `pascalCase`, …), - `generateClient` (also re-exported from the root behind a dynamic import) and `collectGeneratedFiles` for in-memory generation. From 9f08ce80d4e7581096626a89c1349de5dc243729 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20=C5=81=C4=99kawa?= <164185257+JLekawa@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:40:28 +0200 Subject: [PATCH 168/211] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jacek Łękawa <164185257+JLekawa@users.noreply.github.com> --- docs/@v2/configuration/reference/client.md | 2 +- docs/@v2/guides/customize-client-generation.md | 8 ++++---- docs/@v2/guides/use-generated-client.md | 6 ++++-- docs/@v2/usage-data.md | 12 ++++++++++-- tests/e2e/generate-client/examples/go-sdk/README.md | 3 ++- 5 files changed, 21 insertions(+), 10 deletions(-) diff --git a/docs/@v2/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md index 97fcec2745..fdd657973f 100644 --- a/docs/@v2/configuration/reference/client.md +++ b/docs/@v2/configuration/reference/client.md @@ -36,7 +36,7 @@ As an alternative, pass `pagination` to the programmatic `generateClient(...)`. | `queryKeyPrefix` | string | The first element of every `tanstack-query` query key and mutation key. It separates the cache entries when several generated APIs share one QueryClient. This option is available only in the configuration file and has no flag. | | `codeSamples` | boolean | Emit `.code-samples.yaml` next to the client. This file is an OpenAPI Overlay that adds `x-codeSamples` to each operation. The samples come from each selected generator that implements `sample()`. This option is available only in the configuration file and has no flag. | | `serverUrl` | string | The server URL that the client includes as its default. If you do not set it, the client uses `servers[0].url`. | -| `goPackage` | string | The package clause for the output of the `go` generator. The value must be a valid Go package name: lowercase letters, digits, and `_`, with no digit at the start, and not a keyword. An invalid value stops generation, so the generator does not emit a file that Go cannot compile. Default `client`. | +| `goPackage` | string | The package clause for the output of the `go` generator. The value must be a valid Go package name: lowercase letters, digits, and `_`, with no digit at the start, and not a keyword. An invalid value stops generation, so the generator does not emit a file that Go cannot compile. Default: `client`. | | `binName` | string | The command name that the `cli` generator shows in the help output and uses to derive its credential environment variables. The default is the output file name (without extension), sanitized. | | `cliOutput` | string | The path of a composed CLI entry. The entry includes every api that emits a cli module: from the `cli` generator by name, ejected, or included as a prerequisite. The result is one binary. You address each api by its alias, and each api has `__*` credential variables. This option is available only in the top-level `client` block. See [Compose and extend the CLI](../../guides/use-generated-client.md#compose-and-extend-the-cli). | | `options` | object | Options for each generator, keyed by generator name. The command validates each entry against the schema that the generator declares. See [Custom generators](../../guides/customize-client-generation.md#custom-generators). | diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index 49ad92509d..2083f8a931 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -1,6 +1,6 @@ # Customize client generation -This page tells you how to control the output of [`generate-client`](../commands/generate-client.md). +Learn how to control the output of [`generate-client`](../commands/generate-client.md). It covers pre-configured publisher defaults and custom generators. This page is for the person who **runs the generator**, for example an SDK publisher or a platform team. To use the generated client, see [Use the generated client](./use-generated-client.md). @@ -126,7 +126,7 @@ Set the version before the generator stays in use longer than the CLI it was wri Examples are a shared repository, a published package, and output that CI regenerates. Without the version, a changed model shape causes incorrect output, not an error. -**A generator can declare its own options** with a JSON Schema. +A generator can declare its own options with a JSON Schema. Publishers then configure it in the same way as the built-in generators: ```js @@ -163,11 +163,11 @@ Each property can have a `default` and a `description`. Validation runs one time per generator before the CLI writes any file. An unknown key, a value of an incorrect type, a value outside an `enum`, or a missing `required` key stops generation. -The error shows the generator's name and the incorrect key. +The error displays the generator's name and the incorrect key. The CLI rejects unknown keys unless the schema sets `additionalProperties: true`. `run` receives `options` with the defaults applied, so a generator reads its options without more checks. -If you set `options` for a selected generator that declares no schema, the CLI shows a warning. +If you set `options` for a selected generator that declares no schema, the CLI displays a warning. Without the warning, the CLI would ignore the entry with no message. ### Language-neutral helpers diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index 646b2079ae..f3e3b8250b 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -858,7 +858,7 @@ There is no advance parameter. The runtime merges the target's query parameters into the next call. Because of this, every page goes through the same declared endpoint: auth and middleware apply unchanged, and the client never gives credentials to a cross-origin URL. Iteration stops when no `rel="next"` is present, and it throws if the target repeats. -A `link` convention rule applies only to operations whose success response _documents_ a `Link` header. +A `link` convention rule applies only to operations whose success response documents a `Link` header. An explicit rule applies in all cases, but it warns when the header is undocumented. `limitParam` is optional metadata for any style. @@ -961,4 +961,6 @@ That is a generator bug, not a style choice. - [`generate-client` command](../commands/generate-client.md) — flags, output modes, and invocation. - [`client` configuration](../configuration/reference/client.md) — the `redocly.yaml` `client` block. -- [Customize client generation](./customize-client-generation.md) — publisher defaults and custom generators. +- **[`generate-client` command](../commands/generate-client.md)** — Learn about the the `generate-client` command's flags, output modes, and invocation +- **[`client` configuration](../configuration/reference/client.md)** — Explore the settings for the `generate-client` command +- **[Customize client generation](./customize-client-generation.md)** — Learn how to control the output of the `generate-client` diff --git a/docs/@v2/usage-data.md b/docs/@v2/usage-data.md index f5fed93166..e30118b693 100644 --- a/docs/@v2/usage-data.md +++ b/docs/@v2/usage-data.md @@ -22,10 +22,18 @@ When you run a command, the CLI collects this data: - the API specification type and version - the names of the lint rules that report errors, warnings, or ignored problems - the Arazzo x-security authentication types -- for `generate-client`: the built-in generators that run, the count of custom generators, the names of the package's own exported helpers that a custom generator imports, the count of APIs that a composed CLI entry (`client.cliOutput`) spans, and a coarse error category if the command fails. +- for `generate-client`: + - the built-in generators that run + - the count of custom generators + - the names of the package's own exported helpers that a custom generator imports + - the count of APIs that a composed CLI entry (`client.cliOutput`) spans + - a coarse error category if the command fails If a path-loaded generator has the `eject-generator` provenance header, the CLI also sends the built-in origin and the version that the generator was ejected from (for example `php@0.2.0`). The CLI never sends the file contents, the file path, or names that the user chose. -- for `eject-generator`: the action (`eject`, `update`, `guidance`), the name of the built-in generator, and a coarse outcome category (such as `success`, `conflicts` with the conflict count, `already-exists`, or `merge-tool-missing`). +- for `eject-generator`: + - the action (`eject`, `update`, `guidance`) + - the name of the built-in generator + - a coarse outcome category (such as `success`, `conflicts` with the conflict count, `already-exists`, or `merge-tool-missing`) For an `--update` run, the CLI also sends the two `@redocly/client-generator` versions: the version that the file was ejected from, and the installed version. The CLI never collects the file contents, paths, or names of custom generators. - the platform (Linux, macOS, Windows) diff --git a/tests/e2e/generate-client/examples/go-sdk/README.md b/tests/e2e/generate-client/examples/go-sdk/README.md index 83d39cb908..23e50eae96 100644 --- a/tests/e2e/generate-client/examples/go-sdk/README.md +++ b/tests/e2e/generate-client/examples/go-sdk/README.md @@ -16,4 +16,5 @@ go run . ``` The example calls the live demo API at `https://api.cafe.redocly.com` and prints three menu item names. -`MenuItem` is a discriminated union, so items arrive as `any`; `UnmarshalMenuItem` dispatches them into `Beverage`/`Dessert` when you need the typed form. +`MenuItem` is a discriminated union, so items arrive as `any`. +`UnmarshalMenuItem` dispatches them into `Beverage`/`Dessert` when you need the typed form. From e271f78b30cc43017434c94f6ab1d101e6788b24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20=C5=81=C4=99kawa?= <164185257+JLekawa@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:47:01 +0200 Subject: [PATCH 169/211] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jacek Łękawa <164185257+JLekawa@users.noreply.github.com> --- docs/@v2/guides/use-generated-client.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index f3e3b8250b..cd83a8cfe4 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -959,8 +959,6 @@ That is a generator bug, not a style choice. ## Resources -- [`generate-client` command](../commands/generate-client.md) — flags, output modes, and invocation. -- [`client` configuration](../configuration/reference/client.md) — the `redocly.yaml` `client` block. - **[`generate-client` command](../commands/generate-client.md)** — Learn about the the `generate-client` command's flags, output modes, and invocation - **[`client` configuration](../configuration/reference/client.md)** — Explore the settings for the `generate-client` command - **[Customize client generation](./customize-client-generation.md)** — Learn how to control the output of the `generate-client` From eb102e22c46320bd00fb944b4c39d12ad82c552d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20=C5=81=C4=99kawa?= <164185257+JLekawa@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:47:37 +0200 Subject: [PATCH 170/211] Apply suggestion from @JLekawa --- docs/@v2/commands/generate-client.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/@v2/commands/generate-client.md b/docs/@v2/commands/generate-client.md index 6c4b76b655..36d44d9837 100644 --- a/docs/@v2/commands/generate-client.md +++ b/docs/@v2/commands/generate-client.md @@ -130,10 +130,7 @@ See [Package runtime](../guides/use-generated-client.md#package-runtime) in the ## Resources -- [Use the generated client](../guides/use-generated-client.md): the runtime API and the add-on generators. -- [`client` configuration](../configuration/reference/client.md): the `redocly.yaml` `client` block. -- [Lint command](./lint.md): validate your API description before you generate a client. - **[Use the generated client](../guides/use-generated-client.md)** - Learn how to use the client produced by the `generate-client` command -- **[`client` configuration](../configuration/reference/client.md)** - explore the settings for the `generate-client` command -- **[Lint command](./lint.md)** - validate your API description before you generate a client -- **[Bundle command](./bundle.md)** - combine a multi-file description into one input file +- **[`client` configuration](../configuration/reference/client.md)** - Explore the settings for the `generate-client` command +- **[Lint command](./lint.md)** - Validate your API description before you generate a client +- **[Bundle command](./bundle.md)** - Combine a multi-file description into one input file From 03ea40204e837885ba99bb04549eafe2086968e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20=C5=81=C4=99kawa?= <164185257+JLekawa@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:48:02 +0200 Subject: [PATCH 171/211] Apply suggestion from @JLekawa --- docs/@v2/configuration/reference/client.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/@v2/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md index fdd657973f..232b6b84e7 100644 --- a/docs/@v2/configuration/reference/client.md +++ b/docs/@v2/configuration/reference/client.md @@ -129,4 +129,5 @@ With this API, you can also register [custom generators](../../guides/customize- ## Resources - [`generate-client` command](../../commands/generate-client.md): flags, output modes, and invocation. -- [Use the generated client](../../guides/use-generated-client.md): the runtime API (auth, retries, middleware, extra generators). +- **[`generate-client` command](../../commands/generate-client.md)** - Learn about the the `generate-client` command's flags, output modes, and invocation +- **[Use the generated client](../../guides/use-generated-client.md)** - Learn how to use the client produced by the `generate-client` command From b44754b08cc1ed794a989a715a79dd21fe5b8594 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20=C5=81=C4=99kawa?= <164185257+JLekawa@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:51:21 +0200 Subject: [PATCH 172/211] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Jacek Łękawa <164185257+JLekawa@users.noreply.github.com> --- docs/@v2/configuration/reference/client.md | 1 - docs/@v2/guides/customize-client-generation.md | 15 ++++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/@v2/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md index 232b6b84e7..945756f1a5 100644 --- a/docs/@v2/configuration/reference/client.md +++ b/docs/@v2/configuration/reference/client.md @@ -128,6 +128,5 @@ With this API, you can also register [custom generators](../../guides/customize- ## Resources -- [`generate-client` command](../../commands/generate-client.md): flags, output modes, and invocation. - **[`generate-client` command](../../commands/generate-client.md)** - Learn about the the `generate-client` command's flags, output modes, and invocation - **[Use the generated client](../../guides/use-generated-client.md)** - Learn how to use the client produced by the `generate-client` command diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index 2083f8a931..f03e6f9457 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -277,13 +277,14 @@ These examples always agree with the SDK. Import-specifier generators execute at generation time. They have the same trust level as any installed dependency that you run. -See the [`typescript-types-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/typescript-types-generator) for the runnable plugin based on `tsType`. -It also shows how to type-import referenced schemas. -See the [`custom-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/custom-generator) for a minimal generator that builds strings. -See the [`nested-facade` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/nested-facade) for a realistic generator that derives an `api..` facade from the description's tags. ## Resources -- [`generate-client` command](../commands/generate-client.md): flags, output modes, and invocation. -- [`client` configuration](../configuration/reference/client.md): the `redocly.yaml` `client` block. -- [Use the generated client](./use-generated-client.md): the guide for the consumer. +## Resources + +- **[`typescript-types-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/typescript-types-generator)** - Learn how to use the runnable plugin based on `tsType` and how to type-import referenced schemas +- **[`custom-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/custom-generator)** - An example of minimal generator that builds strings +- **[`nested-facade` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/nested-facade)** - An example of a realistic generator that derives an `api..` facade from the description's tags. +- **[`generate-client` command](../commands/generate-client.md)** - flags, output modes, and invocation +- **[`client` configuration](../configuration/reference/client.md)** - Learn about the the `generate-client` command's flags, output modes, and invocation +- **[Use the generated client](./use-generated-client.md)** - Learn how to use the client produced by the `generate-client` command From c4a2c61d3bf071f9bb8f3ea370f39036171c10f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20=C5=81=C4=99kawa?= <164185257+JLekawa@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:52:06 +0200 Subject: [PATCH 173/211] Apply suggestion from @JLekawa --- docs/@v2/guides/customize-client-generation.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index f03e6f9457..58d5fa308a 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -122,6 +122,7 @@ The CLI rejects other forms as unreadable and does not guess. If you omit `requiresGenerator`, the CLI assumes the current version. This is acceptable while you iterate. + Set the version before the generator stays in use longer than the CLI it was written for. Examples are a shared repository, a published package, and output that CI regenerates. Without the version, a changed model shape causes incorrect output, not an error. From 8f221df30838bf6e2270af905647b1f2ed029b26 Mon Sep 17 00:00:00 2001 From: JLekawa Date: Thu, 13 Aug 2026 20:00:00 +0200 Subject: [PATCH 174/211] docs(cli): fix linting issues in tables --- docs/@v2/commands/eject-generator.md | 2 +- docs/@v2/commands/generate-client.md | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/@v2/commands/eject-generator.md b/docs/@v2/commands/eject-generator.md index 1956643f19..5ac9331b28 100644 --- a/docs/@v2/commands/eject-generator.md +++ b/docs/@v2/commands/eject-generator.md @@ -28,7 +28,7 @@ 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 files. Default: `./generators`. | | `--update` | boolean | Do a three-way merge of the current built-in version into your customized copy. The command marks conflicts with standard markers. | | `--force` | boolean | Overwrite an existing ejected file and discard the local edits. | diff --git a/docs/@v2/commands/generate-client.md b/docs/@v2/commands/generate-client.md index 36d44d9837..4926fc00a7 100644 --- a/docs/@v2/commands/generate-client.md +++ b/docs/@v2/commands/generate-client.md @@ -45,22 +45,22 @@ 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`. | -| `--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`. | +| `--output-mode` | string | The file layout. See [Choose an output mode](#choose-an-output-mode).
**Possible values:** `single`, `split`. Default: `single`. | +| `--runtime` | string | The location of the client engine. See [Choose a runtime](#choose-a-runtime).
**Possible values:** `inline`, `package`. Default: `inline`. | +| `--import-ext` | string | The extension in the generated relative imports. See [Run with Node directly](../guides/use-generated-client.md#run-with-node-directly).
**Possible values:** `js` (the tsc/bundler convention), `ts` (for Node's built-in type stripping). Default: `js`. | | `--generator` | [string] | The generator to run: a built-in name, or the path or package of a custom generator. Repeat the flag to run more than one generator. Default value is `typescript`. See [Generators](../guides/use-generated-client.md#generators) for the full list. | -| `--args-style` | string | Sets how you pass inputs to operations. See [Argument style](../guides/use-generated-client.md#argument-style).
**Possible values:** `flat`, `grouped`. Default: `flat`. | -| `--error-mode` | string | Sets how operations report HTTP errors. See [Error handling](../guides/use-generated-client.md#error-handling).
**Possible values:** `throw`, `result`. Default: `throw`. | -| `--date-type` | string | The type of the `date`/`date-time` fields. If you use `Date`, also use the `transformers` generator.
**Possible values:** `string`, `Date`. Default: `string`. | -| `--mock-data` | string | The data mode for the `mock` generator.
**Possible values:** `static` (deterministic literals), `faker` (`@faker-js/faker` calls). Default: `static`. | +| `--args-style` | string | Sets how you pass inputs to operations. See [Argument style](../guides/use-generated-client.md#argument-style).
**Possible values:** `flat`, `grouped`. Default: `flat`. | +| `--error-mode` | string | Sets how operations report HTTP errors. See [Error handling](../guides/use-generated-client.md#error-handling).
**Possible values:** `throw`, `result`. Default: `throw`. | +| `--date-type` | string | The type of the `date`/`date-time` fields. If you use `Date`, also use the `transformers` generator.
**Possible values:** `string`, `Date`. Default: `string`. | +| `--mock-data` | string | The data mode for the `mock` generator.
**Possible values:** `static` (deterministic literals), `faker` (`@faker-js/faker` calls). Default: `static`. | | `--mock-seed` | number | The seed for `faker`-mode mocks. Use it to get reproducible data. The command ignores it in `static` mode. | | `--server-url` | string | Overrides the default server URL in the client. The option accepts an absolute URL (`https://api.example.com`) or a relative URL (`/v1`). Defaults to `servers[0].url`. The app can also change the server URL at runtime with `createClient({ serverUrl })` or `configure({ serverUrl })`. See [Authentication](../guides/use-generated-client.md#authentication) in the usage guide. | | `--setup` | string | The path to a publisher setup module that the command includes in the client. Use it to pre-configure defaults, for example the server URL, retries, headers, and middleware. A published SDK then contains these defaults. See [Publisher defaults](../guides/customize-client-generation.md#publisher-defaults). | | `--bin-name` | string | The command name that the `cli` generator prints in the help output. The generator also uses it to derive the names of the credential environment variables. Defaults to the output file name (without extension) with non-word characters converted to `-`. | | `--go-package` | string | The package clause in the output of the `go` generator. It must be a valid Go package name (lowercase letters, digits, and `_`; it must not start with a digit or be a keyword). Default value is `client`. | | `--config` | string | Specify the path to the [configuration file](#generate-from-the-configuration-file). | -| `--help` | boolean | Display help. | -| `--version` | boolean | Display version number. | +| `--help` | boolean | Display help. | +| `--version` | boolean | Display version number. | ## Examples From 1f1131393c2f76725dba365b2d0645ceada00d4d Mon Sep 17 00:00:00 2001 From: JLekawa Date: Thu, 13 Aug 2026 20:08:36 +0200 Subject: [PATCH 175/211] docs(cli): fix lint issues --- docs/@v2/configuration/reference/client.md | 2 +- docs/@v2/guides/customize-client-generation.md | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/docs/@v2/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md index 945756f1a5..6d1aacfe05 100644 --- a/docs/@v2/configuration/reference/client.md +++ b/docs/@v2/configuration/reference/client.md @@ -36,7 +36,7 @@ As an alternative, pass `pagination` to the programmatic `generateClient(...)`. | `queryKeyPrefix` | string | The first element of every `tanstack-query` query key and mutation key. It separates the cache entries when several generated APIs share one QueryClient. This option is available only in the configuration file and has no flag. | | `codeSamples` | boolean | Emit `.code-samples.yaml` next to the client. This file is an OpenAPI Overlay that adds `x-codeSamples` to each operation. The samples come from each selected generator that implements `sample()`. This option is available only in the configuration file and has no flag. | | `serverUrl` | string | The server URL that the client includes as its default. If you do not set it, the client uses `servers[0].url`. | -| `goPackage` | string | The package clause for the output of the `go` generator. The value must be a valid Go package name: lowercase letters, digits, and `_`, with no digit at the start, and not a keyword. An invalid value stops generation, so the generator does not emit a file that Go cannot compile. Default: `client`. | +| `goPackage` | string | The package clause for the output of the `go` generator. The value must be a valid Go package name: lowercase letters, digits, and `_`, with no digit at the start, and not a keyword. An invalid value stops generation, so the generator does not emit a file that Go cannot compile. Default: `client`. | | `binName` | string | The command name that the `cli` generator shows in the help output and uses to derive its credential environment variables. The default is the output file name (without extension), sanitized. | | `cliOutput` | string | The path of a composed CLI entry. The entry includes every api that emits a cli module: from the `cli` generator by name, ejected, or included as a prerequisite. The result is one binary. You address each api by its alias, and each api has `__*` credential variables. This option is available only in the top-level `client` block. See [Compose and extend the CLI](../../guides/use-generated-client.md#compose-and-extend-the-cli). | | `options` | object | Options for each generator, keyed by generator name. The command validates each entry against the schema that the generator declares. See [Custom generators](../../guides/customize-client-generation.md#custom-generators). | diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index 58d5fa308a..4c0da947d7 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -278,9 +278,6 @@ These examples always agree with the SDK. Import-specifier generators execute at generation time. They have the same trust level as any installed dependency that you run. - -## Resources - ## Resources - **[`typescript-types-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/typescript-types-generator)** - Learn how to use the runnable plugin based on `tsType` and how to type-import referenced schemas From f994b2c13ddbcf4af2a7bae7c14f8dc57ad6fef2 Mon Sep 17 00:00:00 2001 From: JLekawa Date: Thu, 13 Aug 2026 20:13:50 +0200 Subject: [PATCH 176/211] docs(cli): run oxfmt on docs only --- .changeset/agent-friendly-generators.md | 2 +- docs/@v2/configuration/reference/client.md | 4 ++-- docs/@v2/guides/customize-client-generation.md | 2 +- docs/@v2/guides/use-generated-client.md | 2 +- docs/@v2/usage-data.md | 10 +++++----- tests/e2e/generate-client/examples/go-sdk/README.md | 1 + .../e2e/generate-client/examples/python-sdk/README.md | 2 +- 7 files changed, 12 insertions(+), 11 deletions(-) diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index e5329cec07..36b2b1e57e 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -9,7 +9,7 @@ Added composable generated CLIs (custom commands, one binary over several APIs v Added language-neutral authoring toolkit with per-generator options. -Added an `eject-generator` command that vendors any built-in generator, with its design as an agent skill, into your repo. +Added an `eject-generator` command that vendors any built-in generator, with its design as an agent skill, into your repo. Renamed pagination operation extension from `x-redocly-pagination` to `x-redoclyPagination`. The previous name is no longer read. diff --git a/docs/@v2/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md index 6d1aacfe05..575b89058b 100644 --- a/docs/@v2/configuration/reference/client.md +++ b/docs/@v2/configuration/reference/client.md @@ -128,5 +128,5 @@ With this API, you can also register [custom generators](../../guides/customize- ## Resources -- **[`generate-client` command](../../commands/generate-client.md)** - Learn about the the `generate-client` command's flags, output modes, and invocation -- **[Use the generated client](../../guides/use-generated-client.md)** - Learn how to use the client produced by the `generate-client` command +- **[`generate-client` command](../../commands/generate-client.md)** - Learn about the the `generate-client` command's flags, output modes, and invocation +- **[Use the generated client](../../guides/use-generated-client.md)** - Learn how to use the client produced by the `generate-client` command diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index 4c0da947d7..519f10d261 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -284,5 +284,5 @@ They have the same trust level as any installed dependency that you run. - **[`custom-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/custom-generator)** - An example of minimal generator that builds strings - **[`nested-facade` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/nested-facade)** - An example of a realistic generator that derives an `api..` facade from the description's tags. - **[`generate-client` command](../commands/generate-client.md)** - flags, output modes, and invocation -- **[`client` configuration](../configuration/reference/client.md)** - Learn about the the `generate-client` command's flags, output modes, and invocation +- **[`client` configuration](../configuration/reference/client.md)** - Learn about the the `generate-client` command's flags, output modes, and invocation - **[Use the generated client](./use-generated-client.md)** - Learn how to use the client produced by the `generate-client` command diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index cd83a8cfe4..024a7c0868 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -959,6 +959,6 @@ That is a generator bug, not a style choice. ## Resources -- **[`generate-client` command](../commands/generate-client.md)** — Learn about the the `generate-client` command's flags, output modes, and invocation +- **[`generate-client` command](../commands/generate-client.md)** — Learn about the the `generate-client` command's flags, output modes, and invocation - **[`client` configuration](../configuration/reference/client.md)** — Explore the settings for the `generate-client` command - **[Customize client generation](./customize-client-generation.md)** — Learn how to control the output of the `generate-client` diff --git a/docs/@v2/usage-data.md b/docs/@v2/usage-data.md index e30118b693..3b798a7153 100644 --- a/docs/@v2/usage-data.md +++ b/docs/@v2/usage-data.md @@ -27,15 +27,15 @@ When you run a command, the CLI collects this data: - the count of custom generators - the names of the package's own exported helpers that a custom generator imports - the count of APIs that a composed CLI entry (`client.cliOutput`) spans - - a coarse error category if the command fails - If a path-loaded generator has the `eject-generator` provenance header, the CLI also sends the built-in origin and the version that the generator was ejected from (for example `php@0.2.0`). - The CLI never sends the file contents, the file path, or names that the user chose. + - a coarse error category if the command fails + If a path-loaded generator has the `eject-generator` provenance header, the CLI also sends the built-in origin and the version that the generator was ejected from (for example `php@0.2.0`). + The CLI never sends the file contents, the file path, or names that the user chose. - for `eject-generator`: - the action (`eject`, `update`, `guidance`) - the name of the built-in generator - a coarse outcome category (such as `success`, `conflicts` with the conflict count, `already-exists`, or `merge-tool-missing`) - For an `--update` run, the CLI also sends the two `@redocly/client-generator` versions: the version that the file was ejected from, and the installed version. - The CLI never collects the file contents, paths, or names of custom generators. + For an `--update` run, the CLI also sends the two `@redocly/client-generator` versions: the version that the file was ejected from, and the installed version. + The CLI never collects the file contents, paths, or names of custom generators. - the platform (Linux, macOS, Windows) - an anonymous ID (a randomly generated identifier that contains no personal information) - the command execution time diff --git a/tests/e2e/generate-client/examples/go-sdk/README.md b/tests/e2e/generate-client/examples/go-sdk/README.md index 23e50eae96..d975b47ce9 100644 --- a/tests/e2e/generate-client/examples/go-sdk/README.md +++ b/tests/e2e/generate-client/examples/go-sdk/README.md @@ -1,6 +1,7 @@ # go-sdk The `go` generator emits `src/api/client.go` — a full Go SDK over the standard library (zero dependencies, Go ≥ 1.21): + - structs with `json` tags - typed-const enums - a context-aware `Client` with `(T, error)` methods diff --git a/tests/e2e/generate-client/examples/python-sdk/README.md b/tests/e2e/generate-client/examples/python-sdk/README.md index fc04ae3141..8db5288dce 100644 --- a/tests/e2e/generate-client/examples/python-sdk/README.md +++ b/tests/e2e/generate-client/examples/python-sdk/README.md @@ -10,7 +10,7 @@ The `python` generator emits `src/api/client.py`. - retries - pagination iterators (`_pages()` / `_items()`) - SSE streaming -- multipart bodies +- multipart bodies No TypeScript is involved — a `python`-only selection never loads the `typescript` package. From 6090b295a08acd63bfe412dc44befbc9481fc959 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Fri, 14 Aug 2026 13:07:27 +0300 Subject: [PATCH 177/211] fix: apply a link pagination convention only where a Link header is documented --- .../authoring/__tests__/pagination.test.ts | 21 +++++++++++++++++++ .../src/authoring/pagination.ts | 14 +++++++++---- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/client-generator/src/authoring/__tests__/pagination.test.ts b/packages/client-generator/src/authoring/__tests__/pagination.test.ts index b88f08129b..388e4860d4 100644 --- a/packages/client-generator/src/authoring/__tests__/pagination.test.ts +++ b/packages/client-generator/src/authoring/__tests__/pagination.test.ts @@ -46,6 +46,27 @@ describe('paginationRuleFor', () => { expect(paginationRuleFor(op(), { ...CURSOR, cursorParam: 'ghost' })).toBeUndefined(); }); + it('applies a link convention only to operations that document a Link header', () => { + // The convention's structural fit signal for `link` is a documented `Link` response + // header — same rule the TypeScript emitter and the docs state. Without the gate, + // `client.pagination.style: link` would attach page iterators to EVERY operation. + const convention = { style: 'link', items: '/items' }; + const plain = op(); + expect(paginationRuleFor(plain, convention)).toBeUndefined(); + + const linked = op({ + successResponseHeaders: [{ name: 'link', schema: { kind: 'scalar', scalar: 'string' } }], + } as unknown as Partial); + expect(paginationRuleFor(linked, convention)).toEqual({ style: 'link', items: '/items' }); + + // An EXPLICIT rule (per-op or extension) is a declaration, not a convention — it + // still applies, mirroring the TypeScript emitter's explicit-rule path. + expect(paginationRuleFor(plain, { operations: { listOrders: convention } })).toEqual({ + style: 'link', + items: '/items', + }); + }); + it('honors exclude and returns undefined without any source', () => { expect( paginationRuleFor(op({ paginationExtension: CURSOR }), { exclude: ['listOrders'] }) diff --git a/packages/client-generator/src/authoring/pagination.ts b/packages/client-generator/src/authoring/pagination.ts index 0b1ab8752c..cec9c69218 100644 --- a/packages/client-generator/src/authoring/pagination.ts +++ b/packages/client-generator/src/authoring/pagination.ts @@ -18,8 +18,9 @@ export type NeutralPaginationRule = { }; /** - * Pagination for one operation. The convention rule applies only when its advance - * parameter exists on the operation (`link` needs none); `exclude` kills every source. + * Pagination for one operation. The convention rule applies only where it structurally + * fits — the advance parameter exists on the operation, or for `link` (which has no + * parameter) the success response documents a `Link` header; `exclude` kills every source. * Returns undefined when the operation does not paginate. */ export function paginationRuleFor( @@ -38,9 +39,14 @@ export function paginationRuleFor( if (rule === undefined && typeof configuration.style === 'string') { const { exclude: _exclude, operations: _operations, ...convention } = configuration; const advance = convention.style === 'cursor' ? convention.cursorParam : convention.offsetParam; + // A convention needs a structural fit signal: the advance parameter for cursor/offset/ + // page, and a documented `Link` response header for `link` (which has no parameter) — + // the same gate the TypeScript emitter applies. Without it, a link convention would + // attach page iterators to every operation in the description. const fits = - convention.style === 'link' || - (typeof advance === 'string' && op.queryParams.some((param) => param.name === advance)); + convention.style === 'link' + ? op.successResponseHeaders?.some((header) => header.name === 'link') === true + : typeof advance === 'string' && op.queryParams.some((param) => param.name === advance); if (fits) rule = convention as Record; } if (rule === undefined || typeof rule.style !== 'string') return undefined; From f17da0c17eeb196cc29e6711716b7baaddb4bb40 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 17 Aug 2026 12:20:26 +0300 Subject: [PATCH 178/211] test: name the cli credential fixtures after the cafe example The runtime cli tests used a product-specific credential prefix. The example API in this repository is the cafe, so the fixtures now use CAFE_SHOP and CAFE_SYNCER. --- .../client-generator/src/runtime/__tests__/cli.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/client-generator/src/runtime/__tests__/cli.test.ts b/packages/client-generator/src/runtime/__tests__/cli.test.ts index 432ab04067..7c407c3bbe 100644 --- a/packages/client-generator/src/runtime/__tests__/cli.test.ts +++ b/packages/client-generator/src/runtime/__tests__/cli.test.ts @@ -227,7 +227,7 @@ describe('custom commands (composition)', () => { describe('multi-source runCli (one binary, several APIs)', () => { function sources(overrides: { rootCommands?: CustomCommand[] } = {}) { const main = fakeWiring(); - const syncer = fakeWiring({ envPrefix: 'REUNITE_SYNCER' }); + const syncer = fakeWiring({ envPrefix: 'CAFE_SYNCER' }); const root = fakeWiring(); return { main, @@ -309,18 +309,18 @@ describe('multi-source runCli (one binary, several APIs)', () => { describe('wiring.envPrefix', () => { it('overrides the credential prefix without changing the displayed name', async () => { - const { wiring, out } = fakeWiring({ envPrefix: 'REUNITE_MAIN' }); + const { wiring, out } = fakeWiring({ envPrefix: 'CAFE_SHOP' }); await runCli(COMMANDS, wiring, ['--help']); const help = out.join('\n'); expect(help).toContain('Usage: cafe'); - expect(help).toContain('REUNITE_MAIN_TOKEN'); + expect(help).toContain('CAFE_SHOP_TOKEN'); expect(help).not.toContain('CAFE_TOKEN'); }); it('reads credentials under the override', async () => { const { wiring, calls, configured } = fakeWiring({ - envPrefix: 'REUNITE_MAIN', - env: { REUNITE_MAIN_TOKEN: 'tok' }, + envPrefix: 'CAFE_SHOP', + env: { CAFE_SHOP_TOKEN: 'tok' }, results: { getOrder: {} }, }); await runCli(COMMANDS, wiring, ['orders', 'getOrder', 'ord_1']); From 381a54bcc3dc80faf284c5ee695f0848f1877fd4 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 17 Aug 2026 12:20:50 +0300 Subject: [PATCH 179/211] feat(client-generator): add the sdk-docs generator sdk-docs writes one Markdown reference for each SDK selected in the same run (.python.md, .go.md, and so on). A page carries the security schemes and one section per operation: the method and path, the parameters, the request body, the response type, and a call sample in that language. The call samples come from each SDK generator's own `sample` hook, which the pipeline passes to `run` as the new `samples` input. Because of this, a page cannot drift from the SDK beside it, and the ejected copy does not carry the SDK generators. Selecting sdk-docs without an SDK fails with the fix in the message: the generator never pulls a language in on its own. Also clarifies `wiring.env` in the generated-CLI guide. The field is the injected environment map, and a wrapper that stores a token writes the token to `process.env` before it runs a command. --- .changeset/agent-friendly-generators.md | 2 +- docs/@v2/commands/eject-generator.md | 2 +- docs/@v2/configuration/reference/client.md | 2 +- .../@v2/guides/customize-client-generation.md | 5 + docs/@v2/guides/use-generated-client.md | 38 +++- packages/cli/src/commands/eject-generator.ts | 1 + packages/cli/src/index.ts | 2 +- .../src/utils/client-generator-telemetry.ts | 1 + .../skills/sdk-docs-generator/SKILL.md | 70 +++++++ .../scripts/generate-eject-assets.mjs | 6 + .../client-generator/src/emitters/sdk-docs.ts | 195 ++++++++++++++++++ .../client-generator/src/generators/index.ts | 2 + .../client-generator/src/generators/meta.ts | 15 ++ .../src/generators/sdk-docs/AGENTS.md | 63 ++++++ .../src/generators/sdk-docs/index.ts | 83 ++++++++ .../client-generator/src/generators/types.ts | 10 + packages/client-generator/src/pipeline.ts | 8 + tests/e2e/generate-client/sdk-docs.test.ts | 91 ++++++++ 18 files changed, 589 insertions(+), 7 deletions(-) create mode 100644 packages/client-generator/eject-assets/skills/sdk-docs-generator/SKILL.md create mode 100644 packages/client-generator/src/emitters/sdk-docs.ts create mode 100644 packages/client-generator/src/generators/sdk-docs/AGENTS.md create mode 100644 packages/client-generator/src/generators/sdk-docs/index.ts create mode 100644 tests/e2e/generate-client/sdk-docs.test.ts diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index 36b2b1e57e..76857f8237 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -3,7 +3,7 @@ '@redocly/cli': minor --- -Added agent-friendly client generation: `python`, `go`, `php`, `cli`, and `cli-docs` generators in addition to TypeScript generators. +Added agent-friendly client generation: `python`, `go`, `php`, `cli`, `cli-docs`, and `sdk-docs` generators in addition to TypeScript generators. Added composable generated CLIs (custom commands, one binary over several APIs via `client.cliOutput`). diff --git a/docs/@v2/commands/eject-generator.md b/docs/@v2/commands/eject-generator.md index 5ac9331b28..be4e9284f4 100644 --- a/docs/@v2/commands/eject-generator.md +++ b/docs/@v2/commands/eject-generator.md @@ -9,7 +9,7 @@ Do not edit it manually. You or your agent edit the generator, and the `redocly generate-client` command rebuilds the client. When the spec changes later, the command regenerates the client and keeps your customization. -You can eject every built-in generator: the SDKs (`typescript`, `python`, `go`, `php`) and the add-on generators (`zod`, `mock`, `cli`, `cli-docs`, `swr`, `tanstack-query`, `transformers`). +You can eject every built-in generator: the SDKs (`typescript`, `python`, `go`, `php`) and the add-on generators (`zod`, `mock`, `cli`, `cli-docs`, `sdk-docs`, `swr`, `tanstack-query`, `transformers`). The `tanstack-query-vue`, `-svelte`, and `-solid` variants are the same generator with one different argument. Eject `tanstack-query` and set the framework in your copy. diff --git a/docs/@v2/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md index 575b89058b..09ce730971 100644 --- a/docs/@v2/configuration/reference/client.md +++ b/docs/@v2/configuration/reference/client.md @@ -24,7 +24,7 @@ As an alternative, pass `pagination` to the programmatic `generateClient(...)`. | Option | Type | Description | | ---------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `generators` | [string] | The generators to run, in order. Each entry is a built-in name (`typescript`, `zod`, `tanstack-query` or its `-vue`/`-svelte`/`-solid` variants, `swr`, `mock`, `transformers`, `cli`, `cli-docs`, `python`, `go`, `php`), or the path or package name of a custom generator. | +| `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`, `cli-docs`, `sdk-docs`, `python`, `go`, `php`), or the path or package name of a custom generator. | | `outputMode` | string | The file layout: `single` or `split`. This option applies to TypeScript output only. The `python`, `go`, and `php` SDKs always emit one self-contained file. | | `runtime` | string | The runtime distribution: `inline` or `package`. This option applies to TypeScript output only. The `python`, `go`, and `php` SDKs always embed their runtime. | | `importExt` | string | The extension in generated relative imports: `js` (default, for tsc and bundlers) or `ts` (for Node's built-in type stripping). This option applies to TypeScript output only. | diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index 519f10d261..081ba73a5f 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -275,6 +275,11 @@ The built-in `typescript` generator is the reference implementation. If you only set the flag, your Redoc docs get a TypeScript example for each operation. These examples always agree with the SDK. +Your generator also receives these hooks. +`run` gets `samples`, the `sample` hook of every selected generator, keyed by generator name. +A generator that writes documentation calls them instead of writing call syntax for a language it does not own. +The built-in `sdk-docs` generator works this way. + Import-specifier generators execute at generation time. They have the same trust level as any installed dependency that you run. diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index 024a7c0868..678aaa3bea 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -23,6 +23,7 @@ Incompatible selections fail immediately with an explanation. | `transformers` | `.transformers.ts`: `transform` functions that parse wire dates to `Date`. | none | | `cli` | `.cli.ts`: a [command-line interface](#generated-cli) for the client, ready to use as a bin. It has typed flags, `--json` bodies, env auth, and `--page-all`. | none | | `cli-docs` | `.cli.md`: a Markdown [reference for the generated CLI](#cli-reference-docs). It lists every command, flag, exit code, and credential variable. | none | +| `sdk-docs` | `..md`: a Markdown [reference for each selected SDK](#sdk-reference-docs). It lists every operation with its parameters and a call sample in that language. | none | ```sh redocly generate-client openapi.yaml --output src/client.ts --generator typescript --generator zod --generator mock @@ -163,9 +164,13 @@ const login: CustomCommand = { process.exit(await runCli([{ commands: [login] }, ...SOURCES], process.argv.slice(2))); ``` -The CLI resolves credentials from `wiring.env`. -A wrapper that reads a credentials file merges the file into that env (`env: { ...process.env, ...stored }`). -Then a stored token behaves the same as a token set in the shell. +The CLI reads credentials from `wiring.env`. +The generated entry sets this field to `process.env`, and the composed entry keeps that value. +If your wrapper keeps a token in a file, write the token to `process.env` before the wrapper runs a command. +Use `Object.assign(process.env, stored)`. +The CLI then reads the token in the same way as a variable from the shell. +If the wrapper must not change the global environment, give the source its own env: +`{ ...source, wiring: { ...source.wiring, env: { ...process.env, ...stored } } }`. The generator itself supplies no credential store and no login command. The auth flow of each API is different, so you supply these parts. This section shows the procedure. @@ -346,6 +351,33 @@ As a result, the TypeScript, Python, PHP, and Go clients of an API share one voc The generator reports each rename with its cause. A publisher who wants a different name can rename the schema or the operation in the description. +#### SDK reference docs + +The `sdk-docs` generator writes one Markdown page for each SDK in the same run. +The page for the `python` generator is `.python.md`, and the page for the `go` generator is `.go.md`. +Each page starts with the API title, the security schemes, and the requirements of that language. +Then it gives one section for each operation. +A section shows the method and path, the parameters, the request body, the response type, and a call sample in that language. + +The call sample comes from the SDK generator itself, through the same hook that produces `codeSamples`. +Because of this, the page shows the syntax of the SDK next to it, and it cannot drift from that SDK. +Select `sdk-docs` together with at least one SDK generator: `typescript`, `python`, `go`, or `php`. +If you select `sdk-docs` alone, the command stops and tells you to add an SDK generator. + +```sh +redocly generate-client openapi.yaml --output src/client.ts --generator python --generator go --generator sdk-docs +``` + +Two options control the pages, under `client.options.sdk-docs`: + +| Option | Type | Description | +| ------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `title` | string | The page heading. The default is ` SDK reference`. If you select more than one SDK, the generator adds the language to your title. | +| `frontmatter` | boolean | Emit YAML front matter (`title`) above the heading, for docs sites that expect it. The default is `false`. | + +For a different structure or wording, [eject the generator](../commands/eject-generator.md). +The renderer is the template, the same as for `cli-docs`. + ## Package runtime By default, the generator embeds the runtime in the generated file, so the client is self-contained. diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts index f5cd338b9f..b137709062 100644 --- a/packages/cli/src/commands/eject-generator.ts +++ b/packages/cli/src/commands/eject-generator.ts @@ -39,6 +39,7 @@ export const EJECTABLE = new Set([ 'transformers', 'cli', 'cli-docs', + 'sdk-docs', ]); /** diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index aade26d929..1406a6b928 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -956,7 +956,7 @@ yargs(hideBin(process.argv)) }, generator: { describe: - 'Generator to run; repeat the flag to run several (default: typescript). Built-in: typescript, zod, tanstack-query, tanstack-query-vue, tanstack-query-svelte, tanstack-query-solid, swr, mock, transformers, cli, cli-docs, python, go, php — or a path/package specifier for a custom generator. What each one emits is in the "Use the generated client" guide. Example: --generator typescript --generator zod', + 'Generator to run; repeat the flag to run several (default: typescript). Built-in: typescript, zod, tanstack-query, tanstack-query-vue, tanstack-query-svelte, tanstack-query-solid, swr, mock, transformers, cli, cli-docs, sdk-docs, python, go, php — or a path/package specifier for a custom generator. What each one emits is in the "Use the generated client" guide. Example: --generator typescript --generator zod', type: 'string', array: true, requiresArg: true, diff --git a/packages/cli/src/utils/client-generator-telemetry.ts b/packages/cli/src/utils/client-generator-telemetry.ts index b98fa51323..56bca8449d 100644 --- a/packages/cli/src/utils/client-generator-telemetry.ts +++ b/packages/cli/src/utils/client-generator-telemetry.ts @@ -30,6 +30,7 @@ export const BUILTIN_GENERATOR_NAMES = new Set([ 'mock', 'cli', 'cli-docs', + 'sdk-docs', 'python', 'go', 'php', diff --git a/packages/client-generator/eject-assets/skills/sdk-docs-generator/SKILL.md b/packages/client-generator/eject-assets/skills/sdk-docs-generator/SKILL.md new file mode 100644 index 0000000000..ba124892a2 --- /dev/null +++ b/packages/client-generator/eject-assets/skills/sdk-docs-generator/SKILL.md @@ -0,0 +1,70 @@ +--- +name: sdk-docs-generator +description: Design of the ejected Redocly `sdk-docs` client generator. Read it, and update it, before changing generators/sdk-docs.mjs. +--- + +# The `sdk-docs` generator — its skill + +This file is the DESIGN of your ejected `sdk-docs` generator (`generators/sdk-docs.mjs`): +**to change the generator, edit this skill first, then make the code match it** — a diff +to `generators/sdk-docs.mjs` that has no covering sentence here is incomplete. + +## What it emits + +One Markdown page for each SDK generator selected in the same run: `.python.md`, +`.go.md`, `.php.md`, `.typescript.md`. A page carries the heading, the +requirements of that language, the security schemes the description declares, and one +section per operation: method and path, a call sample in that language, the parameters, +the request body, the response type, and the behavior notes (paginated, SSE, binary). + +## Design decisions that must hold + +- **No hand-written call syntax.** Every code block on the page comes from the SDK + generator's own `sample` hook — the same hook that produces `codeSamples`. This + generator never writes Python, Go, PHP, or TypeScript itself. A page that spelled out + call syntax would state the SDK a second time and would lie the first time the SDK + changed. +- **The hooks arrive as data.** The pipeline passes `samples` (the `sample` hook of every + selected generator, keyed by generator name) in `GeneratorInput`. Importing the language + generators instead would pull all four of them into this module and into the file + `eject-generator` produces. +- **It documents what is selected, and nothing else.** The pages come from + `selected ∩ {typescript, python, go, php}`. `requires` cannot express "one of these + four", so a selection with no SDK fails in `run` with the fix in the message. It never + pulls an SDK in: adding a language to someone's output because they asked for docs would + be a surprise, and it would emit a megabyte of SDK. +- **No fact is re-derived here.** The page does not name the SDK file, because each + language decides that name (`my-api.ts` becomes `my_api.py`). Parameters, bodies, + responses, and pagination come from the IR, which is what the SDKs are built from too. + What this generator knows by itself is one line per language: the label, the fence + language, and the runtime requirement. +- **Declared options**: `title` (page heading, default ` SDK +reference`) and `frontmatter` (YAML front matter carrying the title, default `false`). + With more than one SDK selected, a caller-supplied `title` gets the language appended, + because two pages must not share one heading. +- **The renderer IS the template.** Publishers who need another structure eject this + generator. No template syntax, no new dependency. +- **Markdown that survives a linter**: ATX headings, a blank line around every block, no + hard tabs, and one sentence per line in prose. +- **Escapes what descriptions contain**: a summary or description is arbitrary text, so + pipes are escaped inside table cells and newlines collapse to spaces. + +## Emitters that implement it + +`emitters/sdk-docs.ts` (the page renderer), over the IR and the `sample` hooks the +pipeline supplies. + +## Ejecting it + +`redocly eject-generator sdk-docs` ships this generator BUNDLED with its renderer — one +small `.mjs` you own, importing `@redocly/client-generator` and `@redocly/openapi-core`. +The language generators are not bundled with it, because the samples arrive as data. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Make `generators/sdk-docs.mjs` match it. +3. Run `redocly generate-client` and inspect the `git diff` of the generated output — + generated files are never hand-edited. + +Newer built-in versions merge in with `redocly eject-generator sdk-docs --update`. diff --git a/packages/client-generator/scripts/generate-eject-assets.mjs b/packages/client-generator/scripts/generate-eject-assets.mjs index dd255fb355..ed7023ba6a 100644 --- a/packages/client-generator/scripts/generate-eject-assets.mjs +++ b/packages/client-generator/scripts/generate-eject-assets.mjs @@ -170,6 +170,12 @@ const TYPESCRIPT = [ run: 'cliDocsGenerator', options: 'cliDocsOptions', }, + { + name: 'sdk-docs', + imports: ['sdkDocsGenerator', 'sdkDocsOptions'], + run: 'sdkDocsGenerator', + options: 'sdkDocsOptions', + }, { name: 'tanstack-query', imports: ['tanstackQueryGenerator'], diff --git a/packages/client-generator/src/emitters/sdk-docs.ts b/packages/client-generator/src/emitters/sdk-docs.ts new file mode 100644 index 0000000000..6a147e43f9 --- /dev/null +++ b/packages/client-generator/src/emitters/sdk-docs.ts @@ -0,0 +1,195 @@ +// The sdk-docs emitter: renders the Markdown reference for one language SDK from the IR +// the SDK itself is built from, plus that generator's own `sample` hook for the call +// snippets. It writes no call syntax of its own — a second spelling of the SDK would +// drift from it the first time either side changed. + +import { Printer } from '../authoring/printer.js'; +import type { CodeSample } from '../generators/types.js'; +import type { + ApiModel, + OperationModel, + ParamModel, + SchemaModel, +} from '../intermediate-representation/model.js'; +import { resolveModelPagination, type PaginationConfig } from './pagination.js'; + +/** What this generator knows about a language that the IR cannot tell it. */ +export type SdkDocsLanguage = { + /** Generator name; also the infix of the page file (`.python.md`). */ + name: string; + /** Display name for the default heading. */ + label: string; + /** Fence language for the call samples. */ + fence: string; + /** What the SDK needs at run time, as one sentence. */ + requires: string; +}; + +export type SdkDocsOptions = { + /** Page heading. */ + title: string; + /** Emit YAML front matter carrying the title, for docs sites that expect it. */ + frontmatter: boolean; + language: SdkDocsLanguage; + /** The call snippet for one operation, from the SDK generator's own `sample` hook. */ + sample: (operation: OperationModel) => CodeSample | undefined; + pagination?: PaginationConfig; +}; + +/** Table-cell-safe text: one line, with pipes and backslashes escaped. */ +function cell(text: string | undefined): string { + return (text ?? '').replace(/\s+/g, ' ').trim().replace(/\\/g, '\\\\').replace(/\|/g, '\\|'); +} + +/** A wire-level type name for a schema — the vocabulary of the description, not of a language. */ +function typeLabel(schema: SchemaModel): string { + switch (schema.kind) { + case 'ref': + return schema.name; + case 'omit': + return schema.base; + case 'scalar': + return schema.scalar; + case 'array': + return `array of ${typeLabel(schema.items)}`; + case 'record': + return `map of ${typeLabel(schema.value)}`; + case 'enum': { + const values = schema.values.map(String); + const shown = values.slice(0, 6).join(', '); + return values.length > 6 ? `enum: ${shown}, and ${values.length - 6} more` : `enum: ${shown}`; + } + case 'literal': + return String(schema.value); + case 'union': + return schema.members.map(typeLabel).join(' or '); + case 'intersection': + return schema.members.map(typeLabel).join(' and '); + case 'object': + return 'object'; + case 'null': + return 'null'; + case 'unknown': + return 'any'; + } +} + +function writeParameterTable(printer: Printer, params: ParamModel[]): void { + printer.line('| Parameter | In | Type | Required | Description |'); + printer.line('| --------- | -- | ---- | -------- | ----------- |'); + for (const param of params) { + printer.line( + `| \`${param.name}\` | ${param.in} | ${cell(typeLabel(param.schema))} | ${ + param.required ? 'yes' : 'no' + } | ${cell(param.description)} |` + ); + } + printer.blank(); +} + +function writeOperation( + printer: Printer, + op: OperationModel, + options: SdkDocsOptions, + paginated: boolean +): void { + printer.line(`### \`${op.specName ?? op.name}\``); + printer.blank(); + if (op.summary !== undefined) { + printer.line(cell(op.summary)); + printer.blank(); + } + printer.line(`\`${op.method.toUpperCase()} ${op.path}\``); + printer.blank(); + + const sample = options.sample(op); + if (sample !== undefined) { + printer.line('```' + options.language.fence); + for (const line of sample.source.replace(/\n+$/, '').split('\n')) printer.line(line); + printer.line('```'); + printer.blank(); + } + + const params = [...op.pathParams, ...op.queryParams, ...op.headerParams]; + if (params.length > 0) writeParameterTable(printer, params); + + if (op.requestBody !== undefined) { + printer.line( + `Body: \`${op.requestBody.contentType}\`${op.requestBody.required ? ', required' : ', optional'}, of type ${typeLabel(op.requestBody.schema)}.` + ); + } + const success = op.successResponses[0]; + printer.line( + success === undefined + ? 'Returns no content.' + : `Returns \`${success.contentType}\`, of type ${typeLabel(success.schema)}.` + ); + if (paginated) { + printer.line('This operation is paginated, so the SDK gives it page and item iterators.'); + } + printer.blank(); +} + +/** The whole page: heading, requirements, security schemes, then every operation by tag. */ +export function renderSdkDocs(model: ApiModel, options: SdkDocsOptions): string { + const printer = new Printer(); + if (options.frontmatter) { + printer.line('---'); + printer.line(`title: ${options.title}`); + printer.line('---'); + printer.blank(); + } + printer.line(`# ${options.title}`); + printer.blank(); + printer.line( + `Generated reference for the ${options.language.label} SDK, produced from the API description by \`redocly generate-client\`.` + ); + printer.line('Re-run generation to update it — this file is not hand-edited.'); + printer.blank(); + printer.line(options.language.requires); + printer.blank(); + + printer.line('## Authentication'); + printer.blank(); + if (model.securitySchemes.length === 0) { + printer.line('The description declares no security schemes.'); + } else { + printer.line('The description declares these schemes, which you pass to the client:'); + printer.blank(); + printer.line('| Scheme | Kind | Sent as |'); + printer.line('| ------ | ---- | ------- |'); + for (const scheme of model.securitySchemes) { + const sentAs = + scheme.kind === 'bearer' + ? '`Authorization: Bearer `' + : scheme.kind === 'basic' + ? '`Authorization: Basic `' + : scheme.kind === 'apiKeyHeader' + ? `the \`${scheme.headerName}\` header` + : scheme.kind === 'apiKeyQuery' + ? `the \`${scheme.paramName}\` query parameter` + : `the \`${scheme.cookieName}\` cookie`; + printer.line(`| \`${scheme.key}\` | ${scheme.kind} | ${sentAs} |`); + } + } + printer.blank(); + + // One section per tag, in the order the description declares them, then the untagged + // operations — the same grouping the CLI and the split output modes use. + const operations = model.services.flatMap((service) => service.operations); + const paginated = resolveModelPagination(model, options.pagination); + const groups = [...new Set(operations.map((op) => op.tags[0]))]; + for (const group of groups) { + printer.line(group === undefined ? '## Operations' : `## ${group}`); + printer.blank(); + for (const op of operations.filter((candidate) => candidate.tags[0] === group)) { + writeOperation(printer, op, options, paginated.has(op.name)); + } + } + return ( + printer + .toString() + .replace(/\n{3,}/g, '\n\n') + .trimEnd() + '\n' + ); +} diff --git a/packages/client-generator/src/generators/index.ts b/packages/client-generator/src/generators/index.ts index 1aaa39e7fa..a72f419fb7 100644 --- a/packages/client-generator/src/generators/index.ts +++ b/packages/client-generator/src/generators/index.ts @@ -6,6 +6,7 @@ import { BUILTIN_META, validateSelection, type BuiltinMeta } from './meta.js'; import { mockGenerator } from './mock/index.js'; import { phpGenerator, phpSample } from './php/index.js'; import { pythonGenerator, pythonSample } from './python/index.js'; +import { sdkDocsGenerator } from './sdk-docs/index.js'; import { swrGenerator } from './swr/index.js'; import { tanstackQueryGenerator } from './tanstack-query/index.js'; import { transformersGenerator } from './transformers/index.js'; @@ -37,6 +38,7 @@ const RUNS: Record> = mock: { run: mockGenerator }, cli: { run: cliGenerator, sample: cliSample }, 'cli-docs': { run: cliDocsGenerator }, + 'sdk-docs': { run: sdkDocsGenerator }, python: { run: pythonGenerator, sample: pythonSample }, go: { run: goGenerator, sample: goSample }, php: { run: phpGenerator, sample: phpSample }, diff --git a/packages/client-generator/src/generators/meta.ts b/packages/client-generator/src/generators/meta.ts index 5c07b9385a..b6dbd25226 100644 --- a/packages/client-generator/src/generators/meta.ts +++ b/packages/client-generator/src/generators/meta.ts @@ -98,6 +98,21 @@ export const BUILTIN_META: Record = { options: m.cliDocsOptions, })), }, + // sdk-docs renders one Markdown page per SDK selected beside it. It requires nothing: + // `requires` cannot say "one of typescript, python, go, php", and pulling an SDK in + // because docs were asked for would emit a whole SDK nobody selected. + 'sdk-docs': { + notApplicable: { + outputMode: 'it emits one Markdown page per SDK', + importExt: 'a Markdown page has no imports', + runtime: 'a Markdown page embeds no runtime', + }, + load: () => + import('./sdk-docs/index.js').then((m) => ({ + run: m.sdkDocsGenerator, + options: m.sdkDocsOptions, + })), + }, // python emits a standalone full Python SDK (httpx) — no TypeScript involved, // so a python-only selection never loads the `typescript` package. python: { diff --git a/packages/client-generator/src/generators/sdk-docs/AGENTS.md b/packages/client-generator/src/generators/sdk-docs/AGENTS.md new file mode 100644 index 0000000000..0a7075c6ee --- /dev/null +++ b/packages/client-generator/src/generators/sdk-docs/AGENTS.md @@ -0,0 +1,63 @@ +# The `sdk-docs` generator — its skill + +This file is the generator's DESIGN and governs our own changes: **to change the +generator, edit this skill first, then make the code match it.** + +## What it emits + +One Markdown page for each SDK generator selected in the same run: `.python.md`, +`.go.md`, `.php.md`, `.typescript.md`. A page carries the heading, the +requirements of that language, the security schemes the description declares, and one +section per operation: method and path, a call sample in that language, the parameters, +the request body, the response type, and the behavior notes (paginated, SSE, binary). + +## Design decisions that must hold + +- **No hand-written call syntax.** Every code block on the page comes from the SDK + generator's own `sample` hook — the same hook that produces `codeSamples`. This + generator never writes Python, Go, PHP, or TypeScript itself. A page that spelled out + call syntax would state the SDK a second time and would lie the first time the SDK + changed. +- **The hooks arrive as data.** The pipeline passes `samples` (the `sample` hook of every + selected generator, keyed by generator name) in `GeneratorInput`. Importing the language + generators instead would pull all four of them into this module and into the file + `eject-generator` produces. +- **It documents what is selected, and nothing else.** The pages come from + `selected ∩ {typescript, python, go, php}`. `requires` cannot express "one of these + four", so a selection with no SDK fails in `run` with the fix in the message. It never + pulls an SDK in: adding a language to someone's output because they asked for docs would + be a surprise, and it would emit a megabyte of SDK. +- **No fact is re-derived here.** The page does not name the SDK file, because each + language decides that name (`my-api.ts` becomes `my_api.py`). Parameters, bodies, + responses, and pagination come from the IR, which is what the SDKs are built from too. + What this generator knows by itself is one line per language: the label, the fence + language, and the runtime requirement. +- **Declared options**: `title` (page heading, default ` SDK +reference`) and `frontmatter` (YAML front matter carrying the title, default `false`). + With more than one SDK selected, a caller-supplied `title` gets the language appended, + because two pages must not share one heading. +- **The renderer IS the template.** Publishers who need another structure eject this + generator. No template syntax, no new dependency. +- **Markdown that survives a linter**: ATX headings, a blank line around every block, no + hard tabs, and one sentence per line in prose. +- **Escapes what descriptions contain**: a summary or description is arbitrary text, so + pipes are escaped inside table cells and newlines collapse to spaces. + +## Emitters that implement it + +`emitters/sdk-docs.ts` (the page renderer), over the IR and the `sample` hooks the +pipeline supplies. + +## Ejecting it + +`redocly eject-generator sdk-docs` ships this generator BUNDLED with its renderer — one +small `.mjs` you own, importing `@redocly/client-generator` and `@redocly/openapi-core`. +The language generators are not bundled with it, because the samples arrive as data. + +## The modify loop + +1. Edit this skill: state the new behavior or decision. +2. Change `emitters/sdk-docs.ts` (the entry is plumbing — it rarely moves). +3. Verify: `npm run compile`, the emitter unit suites + (`VITEST_SUITE=unit npx vitest run packages/client-generator/src/emitters`), and + `tests/e2e/generate-client/sdk-docs.test.ts`. diff --git a/packages/client-generator/src/generators/sdk-docs/index.ts b/packages/client-generator/src/generators/sdk-docs/index.ts new file mode 100644 index 0000000000..011ac76f2c --- /dev/null +++ b/packages/client-generator/src/generators/sdk-docs/index.ts @@ -0,0 +1,83 @@ +import { join } from 'node:path'; + +import { renderSdkDocs, type SdkDocsLanguage } from '../../emitters/sdk-docs.js'; +import { NotSupportedError } from '../../errors.js'; +import { anchor } from '../anchor.js'; +import type { Generator, GeneratorOptionsSchema } from '../types.js'; + +/** + * The sdk-docs generator: one Markdown page per SDK selected in the same run + * (`.python.md`, `.go.md`, …). Each page renders from the IR the SDK is built + * from, and takes its call snippets from that SDK generator's own `sample` hook, so a page + * never spells out call syntax a second time. + */ +export const sdkDocsOptions: GeneratorOptionsSchema = { + type: 'object', + properties: { + title: { + type: 'string', + description: 'Page heading. Defaults to " SDK reference".', + }, + frontmatter: { + type: 'boolean', + default: false, + description: 'Emit YAML front matter carrying the title, for docs sites that expect it.', + }, + }, + additionalProperties: false, +}; + +/** The SDKs this generator documents, and the one line each needs beyond the IR. */ +const LANGUAGES: SdkDocsLanguage[] = [ + { + name: 'typescript', + label: 'TypeScript', + fence: 'typescript', + requires: 'The client has no dependencies.', + }, + { name: 'python', label: 'Python', fence: 'python', requires: 'The SDK needs `httpx`.' }, + { + name: 'go', + label: 'Go', + fence: 'go', + requires: 'The SDK needs the standard library only.', + }, + { name: 'php', label: 'PHP', fence: 'php', requires: 'The SDK needs the curl extension.' }, +]; + +export const sdkDocsGenerator: Generator = ({ + model, + outputPath, + emit, + options, + selected, + samples, +}) => { + const documented = LANGUAGES.filter((language) => selected?.includes(language.name)); + if (documented.length === 0) { + throw new NotSupportedError( + 'The "sdk-docs" generator documents an SDK, so also select one of: typescript, python, go, php.' + ); + } + const { dir, stem } = anchor(outputPath); + const title = options?.title as string | undefined; + return documented.map((language) => { + const sample = samples?.[language.name]; + return { + path: join(dir, `${stem}.${language.name}.md`), + content: renderSdkDocs(model, { + // Two pages must not share one heading, so a caller's title carries the language. + title: + title === undefined + ? `${model.title} ${language.label} SDK reference` + : documented.length > 1 + ? `${title} (${language.label})` + : title, + frontmatter: options?.frontmatter === true, + language, + sample: (operation) => sample?.(operation, { model, emit }), + pagination: emit.pagination, + }), + }; + }); +}; diff --git a/packages/client-generator/src/generators/types.ts b/packages/client-generator/src/generators/types.ts index d0cd2295c1..cfea9ed367 100644 --- a/packages/client-generator/src/generators/types.ts +++ b/packages/client-generator/src/generators/types.ts @@ -29,6 +29,7 @@ export type GeneratorName = | 'mock' | 'cli' | 'cli-docs' + | 'sdk-docs' | 'python' | 'go' | 'php'; @@ -64,6 +65,15 @@ export type GeneratorInput = { emit: EmitOptions; /** Every generator name in the run — lets a generator adapt to co-selection (cli wires zod validation when `zod` is selected). */ selected?: string[]; + /** + * The `sample` hook of every selected generator that declares one, keyed by generator + * name. A docs generator renders each SDK's own call snippet from these instead of + * importing the SDK generators, which would pull all of them into its bundle. + */ + samples?: Record< + string, + (operation: OperationModel, ctx: SampleContext) => CodeSample | undefined + >; /** * This generator's own options from `client.options.`, already validated against * the schema it declares with defaults applied — a generator reads them without re-checking. diff --git a/packages/client-generator/src/pipeline.ts b/packages/client-generator/src/pipeline.ts index d29e79dd31..b900ba846f 100644 --- a/packages/client-generator/src/pipeline.ts +++ b/packages/client-generator/src/pipeline.ts @@ -49,6 +49,13 @@ 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)); + // The sample hooks of this run, so a docs generator renders each SDK's own call snippet + // without importing the SDK generators. + const samples: Record> = {}; + for (const name of options.generators) { + const sample = options.registry.get(name)!.sample; + if (sample !== undefined) samples[name] = sample; + } for (const name of options.generators) { const generator = options.registry.get(name)!; let generated: GeneratedFile[]; @@ -59,6 +66,7 @@ export function runGenerators( outputMode: options.outputMode, emit: options.emit, selected: options.generators, + samples, options: options.generatorOptions?.get(name) ?? {}, }); } catch (error) { diff --git a/tests/e2e/generate-client/sdk-docs.test.ts b/tests/e2e/generate-client/sdk-docs.test.ts new file mode 100644 index 0000000000..a8b19ececa --- /dev/null +++ b/tests/e2e/generate-client/sdk-docs.test.ts @@ -0,0 +1,91 @@ +// The sdk-docs generator end-to-end: one page per selected SDK, and each page must show +// the call syntax of the SDK beside it — so the bar is the snippet each language +// generator produces, not a snippet this test invents. +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { generate } from './helpers.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixture = join(__dirname, 'fixtures/cli.yaml'); + +let dir: string; +let python: string; +let go: string; + +vi.setConfig({ testTimeout: 120_000 }); + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'sdk-docs-')); + generate(fixture, join(dir, 'cafe.client.ts'), [ + '--generator', + 'python', + '--generator', + 'go', + '--generator', + 'sdk-docs', + ]); + python = readFileSync(join(dir, 'cafe.client.python.md'), 'utf-8'); + go = readFileSync(join(dir, 'cafe.client.go.md'), 'utf-8'); +}); + +afterAll(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe('generate-client sdk-docs generator (end-to-end)', () => { + it('writes one page per selected SDK, and none for an SDK that is not selected', () => { + expect(existsSync(join(dir, 'cafe.client.python.md'))).toBe(true); + expect(existsSync(join(dir, 'cafe.client.go.md'))).toBe(true); + expect(existsSync(join(dir, 'cafe.client.typescript.md'))).toBe(false); + expect(existsSync(join(dir, 'cafe.client.php.md'))).toBe(false); + }); + + it('documents every operation, grouped by tag, with its method and path', () => { + for (const page of [python, go]) { + expect(page).toContain('## orders'); + for (const operation of ['listOrders', 'createOrder', 'getOrder', 'ping']) { + expect(page).toContain(`### \`${operation}\``); + } + expect(page).toContain('`GET /orders/{orderId}`'); + } + }); + + it('shows each language its own call syntax, taken from that generator', () => { + expect(python).toContain('```python'); + expect(python).toContain('client.list_orders('); + expect(go).toContain('```go'); + expect(go).toContain('client.ListOrders('); + // Each page carries one language: the Python page never shows the Go call. + expect(python).not.toContain('client.ListOrders('); + expect(go).not.toContain('client.list_orders('); + }); + + it('carries the parameters, the body, and the security schemes from the description', () => { + expect(python).toContain('| `status` | query |'); + expect(python).toContain('| `orderId` | path |'); + expect(python).toContain('application/json'); + expect(python).toContain('BearerAuth'); + }); + + it('fails with the fix in the message when no SDK is selected', () => { + expect(() => generate(fixture, join(dir, 'alone.ts'), ['--generator', 'sdk-docs'])).toThrow( + /also select/ + ); + }); + + it('is well-formed Markdown: one H1, balanced fences, no tabs or trailing spaces', () => { + const lines = python.split('\n'); + expect(lines.filter((line) => line.startsWith('# '))).toHaveLength(1); + expect(lines.filter((line) => line.startsWith('```')).length % 2).toBe(0); + expect(python).not.toContain('\t'); + expect(lines.filter((line) => /\s$/.test(line))).toEqual([]); + for (let index = 1; index < lines.length; index++) { + if (lines[index].startsWith('|') && lines[index - 1] !== '') { + expect(lines[index - 1].startsWith('|')).toBe(true); + } + } + }); +}); From d5442837ac9a67a1d5141e5a910e1d0fd6724575 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 17 Aug 2026 12:41:19 +0300 Subject: [PATCH 180/211] docs: drop the language-comparison table that repeated the reference The table restated the errorMode, dateType, outputMode, and runtime rows of the client reference, and the response-header, auth, and reserved-name text of the sections around it. The namespacing facts and the warning for an option a language cannot apply were the only parts stated nowhere else, so they move into the Language SDKs prose. --- docs/@v2/guides/use-generated-client.md | 23 +++-------------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index 678aaa3bea..e66337c6d5 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -226,6 +226,9 @@ Every capability is the same: typed models with `allOf` flattened, enums, discri The SDKs also include [auth](#authentication), retries with `Retry-After` and jittered backoff, timeouts, idempotency keys, middleware, and pagination iterators. They also include SSE streaming, multipart bodies, binary downloads, typed response-header envelopes, and server-URL helpers for templated servers. Configuration is the same too: [`serverUrl`](../commands/generate-client.md), [`dateType`](../commands/generate-client.md), [`pagination`](../configuration/reference/client.md#pagination-object), and [`codeSamples`](../configuration/reference/client.md) all apply. +Each language names its output in its own way: the Python module comes from the output file name, the PHP namespace comes from the API title, and Go uses `package client` or [`goPackage`](../configuration/reference/client.md). +If you set an option that a language cannot apply, the generator prints a warning with the option name and the reason. +The option never disappears silently. ```python from openapi_client import Client @@ -257,26 +260,6 @@ for order, err := range api.ListOrdersItems(ctx, nil) { } ``` -#### Where the languages genuinely differ - -The SDKs differ only where the language gives no choice: - -| Topic | TypeScript | Python | PHP | Go | -| -------------------- | ---------------------------------- | -------------------------------------- | -------------------------------------------- | ---------------------------------------------- | -| Error handling | `throw` or `result` (`errorMode`) | `throw` or `result` (`errorMode`) | exceptions (the error idiom of the language) | `(T, error)` (the error idiom of the language) | -| Dates (`Date` mode) | `Date` | `datetime` / `date` | `\DateTimeImmutable` | `time.Time` / `Date` | -| Response headers | `{ envelope: true }` per call | `_with_headers()` | `WithHeaders()` | `WithHeaders` | -| Auth credentials | string or provider function | string or callable | string or callable | provider function only (no union types) | -| Reserved-word fields | not applicable | trailing `_` (`type_`), wire name kept | trailing `_`, wire name kept | trailing `_` (`Type_`), `json` tag kept | -| File layout | `single` or `split` (`outputMode`) | one file | one file | one file | -| Namespacing | ES module (the file path) | module name from the output file name | namespace from the API title | `package client`, or `goPackage` | -| Runtime location | embedded or package (`runtime`) | embedded | embedded | embedded | - -`argsStyle` applies only to TypeScript call sites. -Each language SDK follows its own idiom: keyword arguments, named arguments, or a params struct. -If you set an option that a language cannot apply, the generator prints a warning with the option name and the reason. -The option never disappears silently. - #### Auth, middleware, and reserved names by language Auth accepts a static credential, or a provider function that the client resolves for each request: From 5dc9c4217ab0aeec47fd89bf415ad49169f58e2d Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 17 Aug 2026 13:06:21 +0300 Subject: [PATCH 181/211] refactor(client-generator): group the language runtimes under runtime/ The hand-written SDK runtimes sat in three sibling folders at the package root: go-runtime, php-runtime, and python-runtime. They are now runtime/go, runtime/php, and runtime/python, so one folder holds every language runtime. The TypeScript runtime stays in src/runtime, because it is compiled source that ships in lib and that a `runtime: package` client imports. The other three are never compiled: the prepare script embeds them as strings. --- .../eject-assets/skills/go-generator/SKILL.md | 2 +- .../eject-assets/skills/php-generator/SKILL.md | 2 +- .../eject-assets/skills/python-generator/SKILL.md | 2 +- .../client-generator/{go-runtime => runtime/go}/go.mod | 0 .../{go-runtime => runtime/go}/runtime.go | 0 .../{php-runtime => runtime/php}/runtime.php | 0 .../{python-runtime => runtime/python}/_auth.py | 0 .../{python-runtime => runtime/python}/_decode.py | 0 .../{python-runtime => runtime/python}/_errors.py | 0 .../{python-runtime => runtime/python}/_multipart.py | 0 .../{python-runtime => runtime/python}/_paginate.py | 0 .../{python-runtime => runtime/python}/_send.py | 0 .../{python-runtime => runtime/python}/_sse.py | 0 .../{python-runtime => runtime/python}/_url.py | 0 .../client-generator/scripts/generate-runtime-sources.mjs | 8 ++++---- .../src/generators/__tests__/generator-skills.test.ts | 2 +- .../src/generators/__tests__/go-runtime-embed.test.ts | 2 +- .../src/generators/__tests__/php-runtime-embed.test.ts | 2 +- .../src/generators/__tests__/python-runtime-embed.test.ts | 2 +- .../generators/__tests__/runtime-embed-freshness.test.ts | 6 +++--- packages/client-generator/src/generators/go/AGENTS.md | 4 ++-- packages/client-generator/src/generators/php/AGENTS.md | 4 ++-- packages/client-generator/src/generators/python/AGENTS.md | 4 ++-- .../.claude/skills/php-generator/SKILL.md | 2 +- 24 files changed, 21 insertions(+), 21 deletions(-) rename packages/client-generator/{go-runtime => runtime/go}/go.mod (100%) rename packages/client-generator/{go-runtime => runtime/go}/runtime.go (100%) rename packages/client-generator/{php-runtime => runtime/php}/runtime.php (100%) rename packages/client-generator/{python-runtime => runtime/python}/_auth.py (100%) rename packages/client-generator/{python-runtime => runtime/python}/_decode.py (100%) rename packages/client-generator/{python-runtime => runtime/python}/_errors.py (100%) rename packages/client-generator/{python-runtime => runtime/python}/_multipart.py (100%) rename packages/client-generator/{python-runtime => runtime/python}/_paginate.py (100%) rename packages/client-generator/{python-runtime => runtime/python}/_send.py (100%) rename packages/client-generator/{python-runtime => runtime/python}/_sse.py (100%) rename packages/client-generator/{python-runtime => runtime/python}/_url.py (100%) 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 b9aa99a043..d52c1cba8c 100644 --- a/packages/client-generator/eject-assets/skills/go-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/go-generator/SKILL.md @@ -67,7 +67,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 `go-runtime/runtime.go` (gofmt-clean, `go vet`-clean) +- The runtime is hand-written in `runtime/go/runtime.go` (gofmt-clean, `go vet`-clean) and embedded at prepare time. - Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise. 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 c330d99da5..955051041b 100644 --- a/packages/client-generator/eject-assets/skills/php-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/php-generator/SKILL.md @@ -69,7 +69,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 `php-runtime/runtime.php` (`php -l`-clean) and embedded +- The runtime is hand-written in `runtime/php/runtime.php` (`php -l`-clean) and embedded at prepare time. `curl_close` is never called (deprecated since PHP 8.5, no-op since 8.0). - Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise. 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 8b6993cc5a..2b62b8618e 100644 --- a/packages/client-generator/eject-assets/skills/python-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/python-generator/SKILL.md @@ -55,7 +55,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 `python-runtime/*.py` and embedded as strings at prepare +- The runtime is hand-written in `runtime/python/*.py` and embedded as strings at prepare time — generator code never builds runtime logic from templates. - Authored ONLY with the neutral toolkit (`Printer`, naming, schema, pagination helpers) — the dogfooding guard fails otherwise. diff --git a/packages/client-generator/go-runtime/go.mod b/packages/client-generator/runtime/go/go.mod similarity index 100% rename from packages/client-generator/go-runtime/go.mod rename to packages/client-generator/runtime/go/go.mod diff --git a/packages/client-generator/go-runtime/runtime.go b/packages/client-generator/runtime/go/runtime.go similarity index 100% rename from packages/client-generator/go-runtime/runtime.go rename to packages/client-generator/runtime/go/runtime.go diff --git a/packages/client-generator/php-runtime/runtime.php b/packages/client-generator/runtime/php/runtime.php similarity index 100% rename from packages/client-generator/php-runtime/runtime.php rename to packages/client-generator/runtime/php/runtime.php diff --git a/packages/client-generator/python-runtime/_auth.py b/packages/client-generator/runtime/python/_auth.py similarity index 100% rename from packages/client-generator/python-runtime/_auth.py rename to packages/client-generator/runtime/python/_auth.py diff --git a/packages/client-generator/python-runtime/_decode.py b/packages/client-generator/runtime/python/_decode.py similarity index 100% rename from packages/client-generator/python-runtime/_decode.py rename to packages/client-generator/runtime/python/_decode.py diff --git a/packages/client-generator/python-runtime/_errors.py b/packages/client-generator/runtime/python/_errors.py similarity index 100% rename from packages/client-generator/python-runtime/_errors.py rename to packages/client-generator/runtime/python/_errors.py diff --git a/packages/client-generator/python-runtime/_multipart.py b/packages/client-generator/runtime/python/_multipart.py similarity index 100% rename from packages/client-generator/python-runtime/_multipart.py rename to packages/client-generator/runtime/python/_multipart.py diff --git a/packages/client-generator/python-runtime/_paginate.py b/packages/client-generator/runtime/python/_paginate.py similarity index 100% rename from packages/client-generator/python-runtime/_paginate.py rename to packages/client-generator/runtime/python/_paginate.py diff --git a/packages/client-generator/python-runtime/_send.py b/packages/client-generator/runtime/python/_send.py similarity index 100% rename from packages/client-generator/python-runtime/_send.py rename to packages/client-generator/runtime/python/_send.py diff --git a/packages/client-generator/python-runtime/_sse.py b/packages/client-generator/runtime/python/_sse.py similarity index 100% rename from packages/client-generator/python-runtime/_sse.py rename to packages/client-generator/runtime/python/_sse.py diff --git a/packages/client-generator/python-runtime/_url.py b/packages/client-generator/runtime/python/_url.py similarity index 100% rename from packages/client-generator/python-runtime/_url.py rename to packages/client-generator/runtime/python/_url.py diff --git a/packages/client-generator/scripts/generate-runtime-sources.mjs b/packages/client-generator/scripts/generate-runtime-sources.mjs index b3517275d9..7dba49252a 100644 --- a/packages/client-generator/scripts/generate-runtime-sources.mjs +++ b/packages/client-generator/scripts/generate-runtime-sources.mjs @@ -75,7 +75,7 @@ function declaredNames() { return [...names].sort(); } -// The Python runtime (python-runtime/*.py) embeds the same way: hand-authored +// The Python runtime (runtime/python/*.py) embeds the same way: hand-authored // once, stitched into every generated Python client by the python generator. const PYTHON_MODULES = [ '_errors', @@ -87,7 +87,7 @@ const PYTHON_MODULES = [ '_sse', '_multipart', ]; -const pythonDir = join(pkgRoot, 'python-runtime'); +const pythonDir = join(pkgRoot, 'runtime', 'python'); const pythonOut = join(pkgRoot, 'src', 'emitters', 'python-runtime-sources.ts'); const pythonEntries = PYTHON_MODULES.map((name) => { const source = readFileSync(join(pythonDir, `${name}.py`), 'utf-8'); @@ -108,7 +108,7 @@ writeFileSync( ); // The Go runtime embeds the same way (a single stdlib-only module). -const goDir = join(pkgRoot, 'go-runtime'); +const goDir = join(pkgRoot, 'runtime', 'go'); const goOut = join(pkgRoot, 'src', 'emitters', 'go-runtime-sources.ts'); const goSource = readFileSync(join(goDir, 'runtime.go'), 'utf-8'); writeFileSync( @@ -122,7 +122,7 @@ writeFileSync( ); // The PHP runtime embeds the same way (a single curl-only module). -const phpDir = join(pkgRoot, 'php-runtime'); +const phpDir = join(pkgRoot, 'runtime', 'php'); const phpOut = join(pkgRoot, 'src', 'emitters', 'php-runtime-sources.ts'); const phpSource = readFileSync(join(phpDir, 'runtime.php'), 'utf-8'); writeFileSync( 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 f8caa8f38a..688e0f9494 100644 --- a/packages/client-generator/src/generators/__tests__/generator-skills.test.ts +++ b/packages/client-generator/src/generators/__tests__/generator-skills.test.ts @@ -45,7 +45,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(`${name}-runtime/`); + expect(readFileSync(skillPath, 'utf-8')).toContain(`runtime/${name}/`); }); it('ships without repo-only references — the user has no index.ts, prepare, 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 29a3dfa710..875f57ddd4 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, 'go-runtime'), + cwd: join(pkgRoot, 'runtime', 'go'), 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 c1abf9f776..87a5d5cd51 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, 'php-runtime'), + cwd: join(pkgRoot, 'runtime', 'php'), encoding: 'utf-8', }); expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); diff --git a/packages/client-generator/src/generators/__tests__/python-runtime-embed.test.ts b/packages/client-generator/src/generators/__tests__/python-runtime-embed.test.ts index 7a5104a38c..9dbd145175 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, 'python-runtime', name)], + ['-m', 'py_compile', join(pkgRoot, 'runtime', 'python', 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 e6c4bb74a8..2966209819 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, 'go-runtime/runtime.go'), 'utf-8'); + const source = readFileSync(join(pkgRoot, 'runtime/go/runtime.go'), 'utf-8'); expect(GO_RUNTIME_SOURCE, STALE).toBe(source); }); it('php', () => { - const source = readFileSync(join(pkgRoot, 'php-runtime/runtime.php'), 'utf-8'); + const source = readFileSync(join(pkgRoot, 'runtime/php/runtime.php'), 'utf-8'); expect(PHP_RUNTIME_SOURCE, STALE).toBe(source); }); it('python — every module, and no module missing from the snapshot', () => { - const dir = join(pkgRoot, 'python-runtime'); + const dir = join(pkgRoot, 'runtime', 'python'); const onDisk = readdirSync(dir).filter((name) => name.endsWith('.py')); expect(Object.keys(PYTHON_RUNTIME_SOURCES).sort(), STALE).toEqual(onDisk.sort()); const embedded: Record = PYTHON_RUNTIME_SOURCES; diff --git a/packages/client-generator/src/generators/go/AGENTS.md b/packages/client-generator/src/generators/go/AGENTS.md index c8c12b84ba..7c698cfa2d 100644 --- a/packages/client-generator/src/generators/go/AGENTS.md +++ b/packages/client-generator/src/generators/go/AGENTS.md @@ -63,14 +63,14 @@ 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 `go-runtime/runtime.go` (gofmt-clean, `go vet`-clean) +- The runtime is hand-written in `runtime/go/runtime.go` (gofmt-clean, `go vet`-clean) and embedded at prepare time. - Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise. ## The modify loop 1. Edit this skill: state the new behavior or decision. -2. Change `index.ts` (and `go-runtime/runtime.go` for runtime behavior; `gofmt -w` + +2. Change `index.ts` (and `runtime/go/runtime.go` for runtime behavior; `gofmt -w` + `go vet ./...` it, then `npm run prepare -w @redocly/client-generator`). 3. Verify: `npm run compile`, then `VITEST_SUITE=unit npx vitest run packages/client-generator/src/generators/__tests__/go.test.ts` diff --git a/packages/client-generator/src/generators/php/AGENTS.md b/packages/client-generator/src/generators/php/AGENTS.md index ff49c38fb7..9575a48567 100644 --- a/packages/client-generator/src/generators/php/AGENTS.md +++ b/packages/client-generator/src/generators/php/AGENTS.md @@ -65,7 +65,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 `php-runtime/runtime.php` (`php -l`-clean) and embedded +- The runtime is hand-written in `runtime/php/runtime.php` (`php -l`-clean) and embedded at prepare time. `curl_close` is never called (deprecated since PHP 8.5, no-op since 8.0). - Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise. @@ -86,7 +86,7 @@ $idempotencyKey` on mutating methods. ## The modify loop 1. Edit this skill: state the new behavior or decision. -2. Change `index.ts` (and `php-runtime/runtime.php` for runtime behavior; `php -l` it, +2. Change `index.ts` (and `runtime/php/runtime.php` for runtime behavior; `php -l` it, then `npm run prepare -w @redocly/client-generator`). 3. Verify: `npm run compile`, then `VITEST_SUITE=unit npx vitest run packages/client-generator/src/generators/__tests__/php.test.ts` diff --git a/packages/client-generator/src/generators/python/AGENTS.md b/packages/client-generator/src/generators/python/AGENTS.md index caa6e42248..73b6775b89 100644 --- a/packages/client-generator/src/generators/python/AGENTS.md +++ b/packages/client-generator/src/generators/python/AGENTS.md @@ -51,7 +51,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 `python-runtime/*.py` and embedded as strings at prepare +- The runtime is hand-written in `runtime/python/*.py` and embedded as strings at prepare time — generator code never builds runtime logic from templates. - Authored ONLY with the neutral toolkit (`Printer`, naming, schema, pagination helpers) — the dogfooding guard fails otherwise. @@ -59,7 +59,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 `python-runtime/*.py` if runtime behavior changes; then +2. Change `index.ts` (and `runtime/python/*.py` if runtime behavior changes; then `npm run prepare -w @redocly/client-generator` re-embeds). 3. Verify: `npm run compile`, then `VITEST_SUITE=unit npx vitest run packages/client-generator/src/generators/__tests__/python.test.ts` 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 c330d99da5..955051041b 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 @@ -69,7 +69,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 `php-runtime/runtime.php` (`php -l`-clean) and embedded +- The runtime is hand-written in `runtime/php/runtime.php` (`php -l`-clean) and embedded at prepare time. `curl_close` is never called (deprecated since PHP 8.5, no-op since 8.0). - Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise. From ca5b3324479fed64e6412c7bc945e7f6ad37b81c Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 17 Aug 2026 13:07:26 +0300 Subject: [PATCH 182/211] docs(client-generator): say that each generator skill compiles into the shipped asset `src/generators//AGENTS.md` and `eject-assets/skills/-generator/SKILL.md` read as two copies of one document. The second is generated from the first by `npm run prepare`, which rewrites the intro and the modify loop for a user's repository. Each source skill now says so, so nobody edits the generated asset. The note sits in the intro paragraph, which the transform replaces, so the shipped skills are byte-identical. --- packages/client-generator/src/generators/cli-docs/AGENTS.md | 3 +++ packages/client-generator/src/generators/cli/AGENTS.md | 3 +++ packages/client-generator/src/generators/go/AGENTS.md | 3 +++ packages/client-generator/src/generators/mock/AGENTS.md | 3 +++ packages/client-generator/src/generators/php/AGENTS.md | 3 +++ packages/client-generator/src/generators/python/AGENTS.md | 3 +++ packages/client-generator/src/generators/sdk-docs/AGENTS.md | 3 +++ packages/client-generator/src/generators/swr/AGENTS.md | 3 +++ .../client-generator/src/generators/tanstack-query/AGENTS.md | 3 +++ .../client-generator/src/generators/transformers/AGENTS.md | 3 +++ packages/client-generator/src/generators/typescript/AGENTS.md | 3 +++ packages/client-generator/src/generators/zod/AGENTS.md | 3 +++ 12 files changed, 36 insertions(+) diff --git a/packages/client-generator/src/generators/cli-docs/AGENTS.md b/packages/client-generator/src/generators/cli-docs/AGENTS.md index 1bfd4945e4..79ff0daf5a 100644 --- a/packages/client-generator/src/generators/cli-docs/AGENTS.md +++ b/packages/client-generator/src/generators/cli-docs/AGENTS.md @@ -3,6 +3,9 @@ This file is the generator's DESIGN and governs our own changes: **to change the generator, edit this skill first, then make the code match it.** +`npm run prepare` compiles it into `eject-assets/skills/cli-docs-generator/SKILL.md`, +the copy that ships to users — that asset is generated, so never edit it by hand. + ## What it emits `.cli.md` — the Markdown reference for the generated CLI: the usage line, the diff --git a/packages/client-generator/src/generators/cli/AGENTS.md b/packages/client-generator/src/generators/cli/AGENTS.md index 18a37ef3d8..d8bca2cdcb 100644 --- a/packages/client-generator/src/generators/cli/AGENTS.md +++ b/packages/client-generator/src/generators/cli/AGENTS.md @@ -3,6 +3,9 @@ This file is the generator's DESIGN and governs our own changes: **to change the generator, edit this skill first, then make the code match it.** +`npm run prepare` compiles it into `eject-assets/skills/cli-generator/SKILL.md`, +the copy that ships to users — that asset is generated, so never edit it by hand. + ## What it emits A bin-ready `.cli.ts`: one command per operation over the sdk's instance client, diff --git a/packages/client-generator/src/generators/go/AGENTS.md b/packages/client-generator/src/generators/go/AGENTS.md index 7c698cfa2d..f00e612eda 100644 --- a/packages/client-generator/src/generators/go/AGENTS.md +++ b/packages/client-generator/src/generators/go/AGENTS.md @@ -5,6 +5,9 @@ This file is the generator's DESIGN. It ships to users on `redocly eject-generat edit this skill first, then make the code match it** — a diff to `index.ts` that has no covering sentence here is incomplete. +`npm run prepare` compiles it into `eject-assets/skills/go-generator/SKILL.md`, +the copy that ships to users — that asset is generated, so never edit it by hand. + ## What it emits One self-contained `.go` (`package client`): structs with `json` tags, a `Client` diff --git a/packages/client-generator/src/generators/mock/AGENTS.md b/packages/client-generator/src/generators/mock/AGENTS.md index 7e58e2675a..a6a50fd539 100644 --- a/packages/client-generator/src/generators/mock/AGENTS.md +++ b/packages/client-generator/src/generators/mock/AGENTS.md @@ -3,6 +3,9 @@ This file is the generator's DESIGN and governs our own changes: **to change the generator, edit this skill first, then make the code match it.** +`npm run prepare` compiles it into `eject-assets/skills/mock-generator/SKILL.md`, +the copy that ships to users — that asset is generated, so never edit it by hand. + ## What it emits A standalone MSW module: `create()` data factories, `Handler()` / diff --git a/packages/client-generator/src/generators/php/AGENTS.md b/packages/client-generator/src/generators/php/AGENTS.md index 9575a48567..c12e5552a9 100644 --- a/packages/client-generator/src/generators/php/AGENTS.md +++ b/packages/client-generator/src/generators/php/AGENTS.md @@ -5,6 +5,9 @@ This file is the generator's DESIGN. It ships to users on `redocly eject-generat edit this skill first, then make the code match it** — a diff to `index.ts` that has no covering sentence here is incomplete. +`npm run prepare` compiles it into `eject-assets/skills/php-generator/SKILL.md`, +the copy that ships to users — that asset is generated, so never edit it by hand. + ## What it emits One self-contained `.php`: promoted-constructor model classes, a `Client` with one diff --git a/packages/client-generator/src/generators/python/AGENTS.md b/packages/client-generator/src/generators/python/AGENTS.md index 73b6775b89..8c11059b52 100644 --- a/packages/client-generator/src/generators/python/AGENTS.md +++ b/packages/client-generator/src/generators/python/AGENTS.md @@ -5,6 +5,9 @@ This file is the generator's DESIGN. It ships to users on `redocly eject-generat edit this skill first, then make the code match it** — a diff to `index.ts` that has no covering sentence here is incomplete. +`npm run prepare` compiles it into `eject-assets/skills/python-generator/SKILL.md`, +the copy that ships to users — that asset is generated, so never edit it by hand. + ## What it emits One self-contained `.py`: typed dataclass models, a sync `Client` and an async diff --git a/packages/client-generator/src/generators/sdk-docs/AGENTS.md b/packages/client-generator/src/generators/sdk-docs/AGENTS.md index 0a7075c6ee..74f032c952 100644 --- a/packages/client-generator/src/generators/sdk-docs/AGENTS.md +++ b/packages/client-generator/src/generators/sdk-docs/AGENTS.md @@ -3,6 +3,9 @@ This file is the generator's DESIGN and governs our own changes: **to change the generator, edit this skill first, then make the code match it.** +`npm run prepare` compiles it into `eject-assets/skills/sdk-docs-generator/SKILL.md`, +the copy that ships to users — that asset is generated, so never edit it by hand. + ## What it emits One Markdown page for each SDK generator selected in the same run: `.python.md`, diff --git a/packages/client-generator/src/generators/swr/AGENTS.md b/packages/client-generator/src/generators/swr/AGENTS.md index bcf82cd6e4..696c35c8f1 100644 --- a/packages/client-generator/src/generators/swr/AGENTS.md +++ b/packages/client-generator/src/generators/swr/AGENTS.md @@ -3,6 +3,9 @@ This file is the generator's DESIGN and governs our own changes: **to change the generator, edit this skill first, then make the code match it.** +`npm run prepare` compiles it into `eject-assets/skills/swr-generator/SKILL.md`, +the copy that ships to users — that asset is generated, so never edit it by hand. + ## What it emits React SWR hooks over the sdk's exported operation functions: `use()` with a diff --git a/packages/client-generator/src/generators/tanstack-query/AGENTS.md b/packages/client-generator/src/generators/tanstack-query/AGENTS.md index 9ef8d59129..13719301f4 100644 --- a/packages/client-generator/src/generators/tanstack-query/AGENTS.md +++ b/packages/client-generator/src/generators/tanstack-query/AGENTS.md @@ -3,6 +3,9 @@ This file is the generator's DESIGN and governs our own changes: **to change the generator, edit this skill first, then make the code match it.** +`npm run prepare` compiles it into `eject-assets/skills/tanstack-query-generator/SKILL.md`, +the copy that ships to users — that asset is generated, so never edit it by hand. + ## What it emits Query/mutation option factories for TanStack Query — `Options()`, diff --git a/packages/client-generator/src/generators/transformers/AGENTS.md b/packages/client-generator/src/generators/transformers/AGENTS.md index 68faf3bdc8..0485f196e0 100644 --- a/packages/client-generator/src/generators/transformers/AGENTS.md +++ b/packages/client-generator/src/generators/transformers/AGENTS.md @@ -3,6 +3,9 @@ This file is the generator's DESIGN and governs our own changes: **to change the generator, edit this skill first, then make the code match it.** +`npm run prepare` compiles it into `eject-assets/skills/transformers-generator/SKILL.md`, +the copy that ships to users — that asset is generated, so never edit it by hand. + ## What it emits Per-schema `to()` / `from()` converters that turn wire JSON into typed diff --git a/packages/client-generator/src/generators/typescript/AGENTS.md b/packages/client-generator/src/generators/typescript/AGENTS.md index c29743fe98..aca688d1a6 100644 --- a/packages/client-generator/src/generators/typescript/AGENTS.md +++ b/packages/client-generator/src/generators/typescript/AGENTS.md @@ -4,6 +4,9 @@ This file is the generator's DESIGN and governs our own changes: **to change the generator, edit this skill first, then make the code match it** — a diff with no covering sentence here is incomplete. +`npm run prepare` compiles it into `eject-assets/skills/typescript-generator/SKILL.md`, +the copy that ships to users — that asset is generated, so never edit it by hand. + ## What it emits The typed TypeScript client itself: model types with JSDoc, type guards, the `Ops` diff --git a/packages/client-generator/src/generators/zod/AGENTS.md b/packages/client-generator/src/generators/zod/AGENTS.md index 3e932b5b5e..ade51b766f 100644 --- a/packages/client-generator/src/generators/zod/AGENTS.md +++ b/packages/client-generator/src/generators/zod/AGENTS.md @@ -3,6 +3,9 @@ This file is the generator's DESIGN and governs our own changes: **to change the generator, edit this skill first, then make the code match it.** +`npm run prepare` compiles it into `eject-assets/skills/zod-generator/SKILL.md`, +the copy that ships to users — that asset is generated, so never edit it by hand. + ## What it emits A standalone `.zod.ts`: one `export const Schema` per named IR schema, the From fb53dc63639a32bdb8a67851f9346f6b1f474608 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 17 Aug 2026 13:08:19 +0300 Subject: [PATCH 183/211] docs(client-generator): state where a renderer lives The split between generators/ and emitters/ was implicit, so `emitters/cli.ts` looked misplaced. The module map now gives the rule: a generator folder holds the entry, emitters/ holds the renderer that composes with the shared pieces, and the three self-contained language SDKs are the stated exception. --- packages/client-generator/ARCHITECTURE.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/client-generator/ARCHITECTURE.md b/packages/client-generator/ARCHITECTURE.md index 89fa30d8f6..e6720d364f 100644 --- a/packages/client-generator/ARCHITECTURE.md +++ b/packages/client-generator/ARCHITECTURE.md @@ -47,6 +47,18 @@ flowchart LR ## Module map +**Where a renderer lives.** `generators//index.ts` is the entry: it reads the +options, decides the output paths, and calls a renderer. The renderer itself lives in +`emitters/`, because that layer already holds the shared pieces every renderer composes +with — `operation-signature.ts` for the calling convention, `ts-type.ts` for schema +types, `pagination.ts`, `sse.ts`. `emitters/cli.ts` is there for that reason: `cli` +renders from it, `cli-docs` renders the page from the same command table, and the +package entry exports its composed-entry renderer. + +The three language SDKs are the exception. `python`, `go`, and `php` compose with +nothing in `emitters/` — each is one self-contained file in its generator folder, which +is also what lets `eject-generator` hand a user its source instead of a bundle. + | Area | Files | Owns | Depth | | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | Entry | `index.ts`, `types.ts`, `config.ts`, `config-file.ts`, `plugin.ts` | `generateClient` orchestration; public option/result types; config loading; the experimental `@redocly/client-generator` entry (`defineGenerator` + IR types + codegen toolkit) | thin orchestrator | From 2a46d210db5d32b483aa0dfc8ab76b4fa2d5c433 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 17 Aug 2026 13:09:07 +0300 Subject: [PATCH 184/211] docs: stop calling the built-in typescript generator a reference implementation In the custom-generator guide that reads as "copy this generator", but it is a short entry over renderers that are internal to the package, so an author cannot import what it uses. The guide now says the generator implements the sample hook, and points authors at the runnable examples, which use only the public toolkit. --- docs/@v2/guides/customize-client-generation.md | 7 ++++++- .../client-generator/src/generators/typescript/index.ts | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index 081ba73a5f..6c7a1e947e 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -271,10 +271,15 @@ With `codeSamples: true` in the `client` block, generation collects the samples This file is an [OpenAPI Overlay](https://spec.openapis.org/overlay/latest.html) that adds `x-codeSamples` to each operation. Docs tooling can apply the file. -The built-in `typescript` generator is the reference implementation. +The built-in `typescript` generator implements the hook. If you only set the flag, your Redoc docs get a TypeScript example for each operation. These examples always agree with the SDK. +Do not read the built-in TypeScript generators as a model for your own. +Each one is a short entry over renderers that are internal to the package, so you cannot import the parts it uses. +The runnable examples at the end of this page are the model to copy. +They use only the public toolkit. + Your generator also receives these hooks. `run` gets `samples`, the `sample` hook of every selected generator, keyed by generator name. A generator that writes documentation calls them instead of writing call syntax for a language it does not own. diff --git a/packages/client-generator/src/generators/typescript/index.ts b/packages/client-generator/src/generators/typescript/index.ts index cd142397b3..e3f494357d 100644 --- a/packages/client-generator/src/generators/typescript/index.ts +++ b/packages/client-generator/src/generators/typescript/index.ts @@ -29,7 +29,7 @@ export const typescriptGenerator: Generator = ({ model, outputPath, outputMode, return [{ path: outputPath, content: emitClientSingleFile(model, emit) }]; }; -/** One idiomatic TS call per operation — the `x-codeSamples` reference implementation. */ +/** One idiomatic TS call per operation, for `x-codeSamples` and the SDK reference pages. */ export function typescriptSample(op: OperationModel, ctx: SampleContext): CodeSample { const ident = packageIdents(ctx.model).get(op.name) ?? op.name; const requiredQuery = op.queryParams.filter((param) => param.required); From 085210da5c430b52e12e44d3efe6bc9d4ddb94e1 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 17 Aug 2026 14:28:22 +0300 Subject: [PATCH 185/211] fix(client-generator): align the sdk-docs notes with what each SDK does Two findings from the review bot on the new page renderer. The pagination note came from `resolveModelPagination`, the TypeScript verifier, which throws on a rule it cannot check against the response schema. The language SDKs resolve pagination with `paginationRuleFor`, which does not, so a python-only run could fail over a page, and the page could mark a different set of operations than the SDK beside it. The note now comes from the same helper the SDKs use. The skill promised a note for paginated, streaming, and binary operations, and only the paginated note existed. A streaming or binary operation now says so, decided by the success content type, which needs no import of the TypeScript emitters. --- .../client-generator/src/emitters/sdk-docs.ts | 36 +++++++++++++------ .../src/generators/sdk-docs/AGENTS.md | 5 +++ tests/e2e/generate-client/sdk-docs.test.ts | 22 ++++++++++++ 3 files changed, 53 insertions(+), 10 deletions(-) diff --git a/packages/client-generator/src/emitters/sdk-docs.ts b/packages/client-generator/src/emitters/sdk-docs.ts index 6a147e43f9..39f32fd8ce 100644 --- a/packages/client-generator/src/emitters/sdk-docs.ts +++ b/packages/client-generator/src/emitters/sdk-docs.ts @@ -3,6 +3,7 @@ // snippets. It writes no call syntax of its own — a second spelling of the SDK would // drift from it the first time either side changed. +import { paginationRuleFor } from '../authoring/pagination.js'; import { Printer } from '../authoring/printer.js'; import type { CodeSample } from '../generators/types.js'; import type { @@ -11,7 +12,7 @@ import type { ParamModel, SchemaModel, } from '../intermediate-representation/model.js'; -import { resolveModelPagination, type PaginationConfig } from './pagination.js'; +import type { PaginationConfig } from './pagination.js'; /** What this generator knows about a language that the IR cannot tell it. */ export type SdkDocsLanguage = { @@ -74,6 +75,18 @@ function typeLabel(schema: SchemaModel): string { } } +/** Binary success content with no JSON alternative — the same test the clients apply. */ +function isBinary(op: OperationModel): boolean { + if (op.successResponses.some((response) => response.contentType.toLowerCase().includes('json'))) { + return false; + } + return op.successResponses.some( + (response) => + response.contentType.startsWith('image/') || + response.contentType === 'application/octet-stream' + ); +} + function writeParameterTable(printer: Printer, params: ParamModel[]): void { printer.line('| Parameter | In | Type | Required | Description |'); printer.line('| --------- | -- | ---- | -------- | ----------- |'); @@ -87,12 +100,7 @@ function writeParameterTable(printer: Printer, params: ParamModel[]): void { printer.blank(); } -function writeOperation( - printer: Printer, - op: OperationModel, - options: SdkDocsOptions, - paginated: boolean -): void { +function writeOperation(printer: Printer, op: OperationModel, options: SdkDocsOptions): void { printer.line(`### \`${op.specName ?? op.name}\``); printer.blank(); if (op.summary !== undefined) { @@ -124,9 +132,18 @@ function writeOperation( ? 'Returns no content.' : `Returns \`${success.contentType}\`, of type ${typeLabel(success.schema)}.` ); - if (paginated) { + // 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 as Record | undefined)) { printer.line('This operation is paginated, so the SDK gives it page and item iterators.'); } + if (op.successResponses.some((response) => response.contentType === 'text/event-stream')) { + printer.line('This operation streams server-sent events, so the SDK iterates the events.'); + } + if (isBinary(op)) { + printer.line('This operation returns binary content.'); + } printer.blank(); } @@ -177,13 +194,12 @@ export function renderSdkDocs(model: ApiModel, options: SdkDocsOptions): string // One section per tag, in the order the description declares them, then the untagged // operations — the same grouping the CLI and the split output modes use. const operations = model.services.flatMap((service) => service.operations); - const paginated = resolveModelPagination(model, options.pagination); const groups = [...new Set(operations.map((op) => op.tags[0]))]; for (const group of groups) { printer.line(group === undefined ? '## Operations' : `## ${group}`); printer.blank(); for (const op of operations.filter((candidate) => candidate.tags[0] === group)) { - writeOperation(printer, op, options, paginated.has(op.name)); + writeOperation(printer, op, options); } } return ( diff --git a/packages/client-generator/src/generators/sdk-docs/AGENTS.md b/packages/client-generator/src/generators/sdk-docs/AGENTS.md index 74f032c952..adc0481866 100644 --- a/packages/client-generator/src/generators/sdk-docs/AGENTS.md +++ b/packages/client-generator/src/generators/sdk-docs/AGENTS.md @@ -21,6 +21,11 @@ the request body, the response type, and the behavior notes (paginated, SSE, bin generator never writes Python, Go, PHP, or TypeScript itself. A page that spelled out call syntax would state the SDK a second time and would lie the first time the SDK changed. +- **Pagination comes from the SDK's own resolver.** `paginationRuleFor` (the authoring + helper the `python`, `go`, and `php` generators resolve pagination with) decides the + note, so the page marks exactly the operations those SDKs paginate. The TypeScript + verifier, `resolveModelPagination`, would throw on a rule it cannot verify against the + response schema, and that would fail a python-only run over a page. - **The hooks arrive as data.** The pipeline passes `samples` (the `sample` hook of every selected generator, keyed by generator name) in `GeneratorInput`. Importing the language generators instead would pull all four of them into this module and into the file diff --git a/tests/e2e/generate-client/sdk-docs.test.ts b/tests/e2e/generate-client/sdk-docs.test.ts index a8b19ececa..0fa022e44d 100644 --- a/tests/e2e/generate-client/sdk-docs.test.ts +++ b/tests/e2e/generate-client/sdk-docs.test.ts @@ -63,6 +63,28 @@ describe('generate-client sdk-docs generator (end-to-end)', () => { expect(go).not.toContain('client.list_orders('); }); + it('notes the behavior an SDK call has beyond a plain JSON request', () => { + // listOrders declares x-redoclyPagination; the note must come from the resolver the + // language SDKs use, so a page never disagrees with the SDK next to it. + expect(python).toContain('This operation is paginated'); + expect(go).toContain('This operation is paginated'); + + const streaming = mkdtempSync(join(tmpdir(), 'sdk-docs-sse-')); + try { + generate(join(__dirname, 'fixtures/sse.yaml'), join(streaming, 'client.ts'), [ + '--generator', + 'python', + '--generator', + 'sdk-docs', + ]); + expect(readFileSync(join(streaming, 'client.python.md'), 'utf-8')).toContain( + 'streams server-sent events' + ); + } finally { + rmSync(streaming, { recursive: true, force: true }); + } + }); + it('carries the parameters, the body, and the security schemes from the description', () => { expect(python).toContain('| `status` | query |'); expect(python).toContain('| `orderId` | path |'); From eb8a8a404a7a5890c75cf88adf505d2590564cec Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 17 Aug 2026 14:29:23 +0300 Subject: [PATCH 186/211] chore(client-generator): refresh the sdk-docs skill asset The pagination bullet added to the source skill belongs to the design sections, which ship, so the generated asset needed regenerating with it. --- .../eject-assets/skills/sdk-docs-generator/SKILL.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/client-generator/eject-assets/skills/sdk-docs-generator/SKILL.md b/packages/client-generator/eject-assets/skills/sdk-docs-generator/SKILL.md index ba124892a2..467b01bdf1 100644 --- a/packages/client-generator/eject-assets/skills/sdk-docs-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/sdk-docs-generator/SKILL.md @@ -24,6 +24,11 @@ the request body, the response type, and the behavior notes (paginated, SSE, bin generator never writes Python, Go, PHP, or TypeScript itself. A page that spelled out call syntax would state the SDK a second time and would lie the first time the SDK changed. +- **Pagination comes from the SDK's own resolver.** `paginationRuleFor` (the authoring + helper the `python`, `go`, and `php` generators resolve pagination with) decides the + note, so the page marks exactly the operations those SDKs paginate. The TypeScript + verifier, `resolveModelPagination`, would throw on a rule it cannot verify against the + response schema, and that would fail a python-only run over a page. - **The hooks arrive as data.** The pipeline passes `samples` (the `sample` hook of every selected generator, keyed by generator name) in `GeneratorInput`. Importing the language generators instead would pull all four of them into this module and into the file From 25229294ff43b4d7b212e66565870e322928d7fa Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Mon, 17 Aug 2026 16:52:56 +0300 Subject: [PATCH 187/211] docs: explain the word after the bin name, and why TypeScript has extra auth setters Review found the composed-CLI section reads as a contradiction: an earlier section says the word after the bin name is a tag slug, while the composed example puts an api alias there. Both are true and they never met. The guide now compares the two setups, says the tag groups of an api nest inside its alias, and says why the example's shorter form works: a bare operationId resolves when it is unambiguous. An e2e now covers that shorter form, so the documented spelling is verified rather than asserted. `binName` led with what it is not, so it now leads with what it is. The auth section read as three interfaces in TypeScript against one elsewhere. Every language gives credentials to an instance through its constructor; TypeScript adds two setters only because it also exports a module-level client. --- docs/@v2/guides/use-generated-client.md | 24 +++++++++++++++---- tests/e2e/generate-client/cli-compose.test.ts | 10 ++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index e66337c6d5..f44f018847 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -131,15 +131,23 @@ npx tsx src/cafe.ts shop listOrders --limit 3 # CAFE_SHOP_TOKEN npx tsx src/cafe.ts kitchen createOrder --json @o.json # CAFE_KITCHEN_TOKEN ``` -`binName` sets the name in the help output and the prefix of the credential variables. -It does not install a `cafe` executable. -To type `cafe` instead of `npx tsx src/cafe.ts`, compile the entry and point the `bin` field of `package.json` at the compiled file. -The end of this section shows this step. -An operationId is unique only within one description. +Two different things can stand in the word after the bin name, so compare the two setups. +For one API, that word is the tag slug: `cafe orders listOrders`. +For a composed binary, that word is the api alias, and the tag groups of that api nest inside it: `cafe shop orders listOrders`. +The example above is shorter than that, because a bare operationId resolves whenever it is unambiguous. +If two tags of one api declare the same operationId, the CLI reports the ambiguity and names the groups to choose from. + +An operationId is unique only inside one description. Because of this, each command carries its api's alias as a namespace. If two descriptions declare the same operationId, the result is two different commands. Each api keeps its own server URL, schemes, and credentials. +`binName` is the name the CLI uses for itself. +The name appears in every usage line of `--help`, and the credential variables derive from it: `binName: cafe` gives `CAFE_TOKEN`. +It does not create an executable. +To type `cafe` instead of `npx tsx src/cafe.ts`, compile the entry and point the `bin` field of your `package.json` at the compiled file. +The end of this section shows this step. + **Commands the description doesn't have.** A custom command is the same data shape plus a `handler`. Because of this, it inherits the help, the parsing, `schema`, and the exit codes. @@ -262,6 +270,12 @@ for order, err := range api.ListOrdersItems(ctx, nil) { #### Auth, middleware, and reserved names by language +Every language gives credentials to a client instance, and the constructor is that one way. +`createClient(OPERATIONS, { auth })` in TypeScript is the same thing as the constructors below. +TypeScript adds `setBearer` and `configure({ auth })` for one reason: it also exports a module-level client, which the [free functions](#authentication) call. +Those two configure that instance. +The Python, PHP, and Go SDKs export no module-level client, so they need no equivalent. + Auth accepts a static credential, or a provider function that the client resolves for each request: ```python diff --git a/tests/e2e/generate-client/cli-compose.test.ts b/tests/e2e/generate-client/cli-compose.test.ts index dc1d85e28d..a7cb48c9e6 100644 --- a/tests/e2e/generate-client/cli-compose.test.ts +++ b/tests/e2e/generate-client/cli-compose.test.ts @@ -99,6 +99,16 @@ describe('composed CLI (end-to-end)', () => { expect(captured.headers.Authorization).toBe('***'); }); + it('takes an operationId without its group, the form the guide shows', () => { + // `cafe shop listOrders` — the alias, then a bare operationId, because the name is + // unambiguous inside that api. The guide documents this shorter form. + const dry = runEntry(['shop', 'getOrder', 'ord_2', '--dry-run'], { + CAFE_SHOP_TOKEN: 'shop-secret', + }); + expect(dry.code, dry.stderr).toBe(0); + expect(JSON.parse(dry.stdout).url).toContain('/orders/ord_2'); + }); + it('namespace help shows that API; the same operationId lives in both namespaces', () => { const shop = runEntry(['shop', '--help']); const kitchen = runEntry(['kitchen', '--help']); From 217287b760414c03084bff79b61dce884eb5be33 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 18 Aug 2026 13:09:42 +0300 Subject: [PATCH 188/211] refactor(client-generator)!: make documentation a generator hook behind --docs Review asked for one switch instead of a generator name per language, and for each generator to produce its own documentation. Both land together, because they are the same design. `docs` joins `run` and `sample` on the generator descriptor. The pipeline calls it only when `client.docs` (or `--docs`) is set, and its files land beside that generator's own output: `cli` writes `.cli.md`, and `typescript`, `python`, `go`, and `php` each write `..md`. One switch covers every language, so a newly documented generator needs no new flag, and a generator documents itself, which is the only place its call syntax is known. The `cli-docs` and `sdk-docs` generators are gone. So is the `samples` field on GeneratorInput: it existed to hand one generator another's `sample` hook, and a generator documenting itself has its own. Public surface shrinks on both counts. The page renderer moves to `authoring/reference-page.ts` as `renderReferencePage`. Language generators are ejected as source with only two import specifiers rewritten, so a renderer under `emitters/` would be unreachable from an ejected copy; in the toolkit it is reachable, and eject now carries each generator's `docs` hook, so an ejected generator keeps documenting itself and owns the page. Asking for documentation and getting none now warns and names the selection. --- packages/cli/src/commands/eject-generator.ts | 2 - packages/cli/src/commands/generate-client.ts | 2 + packages/cli/src/index.ts | 7 +- .../src/utils/client-generator-telemetry.ts | 2 - .../client-generator/eject-assets/AGENTS.md | 7 + .../skills/cli-docs-generator/SKILL.md | 58 ------- .../skills/cli-generator/SKILL.md | 15 +- .../skills/client-generators/SKILL.md | 7 + .../eject-assets/skills/go-generator/SKILL.md | 8 + .../skills/php-generator/SKILL.md | 8 + .../skills/python-generator/SKILL.md | 8 + .../skills/sdk-docs-generator/SKILL.md | 75 --------- .../skills/typescript-generator/SKILL.md | 8 + .../scripts/generate-eject-assets.mjs | 41 ++--- .../client-generator/src/authoring/index.ts | 8 + .../reference-page.ts} | 34 ++--- .../src/emitters/emit-options.ts | 8 + .../src/generators/__tests__/cli-docs.test.ts | 144 ------------------ .../__tests__/generator-skills.test.ts | 11 +- .../src/generators/cli-docs/AGENTS.md | 55 ------- .../src/generators/cli-docs/index.ts | 49 ------ .../src/generators/cli/AGENTS.md | 15 +- .../src/generators/cli/index.ts | 20 ++- .../src/generators/go/AGENTS.md | 8 + .../src/generators/go/index.ts | 24 +++ .../client-generator/src/generators/index.ts | 26 ++-- .../client-generator/src/generators/meta.ts | 61 +++----- .../src/generators/php/AGENTS.md | 8 + .../src/generators/php/index.ts | 24 +++ .../src/generators/python/AGENTS.md | 8 + .../src/generators/python/index.ts | 24 +++ .../src/generators/sdk-docs/AGENTS.md | 71 --------- .../src/generators/sdk-docs/index.ts | 83 ---------- .../client-generator/src/generators/types.ts | 19 +-- .../src/generators/typescript/AGENTS.md | 8 + .../src/generators/typescript/index.ts | 24 +++ packages/client-generator/src/pipeline.ts | 44 +++--- packages/client-generator/src/types.ts | 10 ++ .../__snapshots__/redocly-yaml.test.ts.snap | 6 + packages/core/src/types/redocly-yaml.ts | 2 + tests/e2e/generate-client/cli-docs.test.ts | 95 ------------ tests/e2e/generate-client/docs.test.ts | 122 +++++++++++++++ tests/e2e/generate-client/examples/README.md | 50 +++--- .../generate-client/examples/cli/README.md | 4 +- .../generate-client/examples/cli/redocly.yaml | 5 +- .../.claude/skills/client-generators/SKILL.md | 7 + .../.claude/skills/php-generator/SKILL.md | 8 + .../examples/python-sdk/README.md | 1 + .../examples/python-sdk/redocly.yaml | 2 + tests/e2e/generate-client/sdk-docs.test.ts | 113 -------------- 50 files changed, 540 insertions(+), 909 deletions(-) delete mode 100644 packages/client-generator/eject-assets/skills/cli-docs-generator/SKILL.md delete mode 100644 packages/client-generator/eject-assets/skills/sdk-docs-generator/SKILL.md rename packages/client-generator/src/{emitters/sdk-docs.ts => authoring/reference-page.ts} (86%) delete mode 100644 packages/client-generator/src/generators/__tests__/cli-docs.test.ts delete mode 100644 packages/client-generator/src/generators/cli-docs/AGENTS.md delete mode 100644 packages/client-generator/src/generators/cli-docs/index.ts delete mode 100644 packages/client-generator/src/generators/sdk-docs/AGENTS.md delete mode 100644 packages/client-generator/src/generators/sdk-docs/index.ts delete mode 100644 tests/e2e/generate-client/cli-docs.test.ts create mode 100644 tests/e2e/generate-client/docs.test.ts delete mode 100644 tests/e2e/generate-client/sdk-docs.test.ts diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts index b137709062..a68ed87bc0 100644 --- a/packages/cli/src/commands/eject-generator.ts +++ b/packages/cli/src/commands/eject-generator.ts @@ -38,8 +38,6 @@ export const EJECTABLE = new Set([ 'tanstack-query', 'transformers', 'cli', - 'cli-docs', - 'sdk-docs', ]); /** diff --git a/packages/cli/src/commands/generate-client.ts b/packages/cli/src/commands/generate-client.ts index 16d77be6b6..a47ccc76a0 100644 --- a/packages/cli/src/commands/generate-client.ts +++ b/packages/cli/src/commands/generate-client.ts @@ -41,6 +41,7 @@ export type GenerateClientCommandArgv = { 'date-type'?: 'string' | 'Date'; 'mock-data'?: 'static' | 'faker'; 'mock-seed'?: number; + docs?: boolean; generator?: string[]; setup?: string; }; @@ -116,6 +117,7 @@ export async function handleGenerateClient({ dateType: argv['date-type'], mockData: argv['mock-data'], mockSeed: argv['mock-seed'], + docs: argv.docs, generators: argv.generator?.map((specifier) => specifier.startsWith('.') ? resolvePath(specifier) : specifier ), diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 1406a6b928..3e62c6d014 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -906,6 +906,11 @@ yargs(hideBin(process.argv)) type: 'string', requiresArg: true, }, + docs: { + description: + 'Also write reference documentation for what this run generates: one Markdown page per selected generator that documents itself (the CLI, and each SDK).', + type: 'boolean', + }, 'go-package': { description: "Package clause of the `go` generator's output (a valid Go package name). Defaults to `client`.", @@ -956,7 +961,7 @@ yargs(hideBin(process.argv)) }, generator: { describe: - 'Generator to run; repeat the flag to run several (default: typescript). Built-in: typescript, zod, tanstack-query, tanstack-query-vue, tanstack-query-svelte, tanstack-query-solid, swr, mock, transformers, cli, cli-docs, sdk-docs, python, go, php — or a path/package specifier for a custom generator. What each one emits is in the "Use the generated client" guide. Example: --generator typescript --generator zod', + 'Generator to run; repeat the flag to run several (default: typescript). Built-in: typescript, zod, tanstack-query, tanstack-query-vue, tanstack-query-svelte, tanstack-query-solid, swr, mock, transformers, cli, python, go, php — or a path/package specifier for a custom generator. What each one emits is in the "Use the generated client" guide. Example: --generator typescript --generator zod', type: 'string', array: true, requiresArg: true, diff --git a/packages/cli/src/utils/client-generator-telemetry.ts b/packages/cli/src/utils/client-generator-telemetry.ts index 56bca8449d..beb0b56d7c 100644 --- a/packages/cli/src/utils/client-generator-telemetry.ts +++ b/packages/cli/src/utils/client-generator-telemetry.ts @@ -29,8 +29,6 @@ export const BUILTIN_GENERATOR_NAMES = new Set([ 'transformers', 'mock', 'cli', - 'cli-docs', - 'sdk-docs', 'python', 'go', 'php', diff --git a/packages/client-generator/eject-assets/AGENTS.md b/packages/client-generator/eject-assets/AGENTS.md index 370c6e0027..208ccebeb4 100644 --- a/packages/client-generator/eject-assets/AGENTS.md +++ b/packages/client-generator/eject-assets/AGENTS.md @@ -23,6 +23,12 @@ export default { sample(operation, { model, emit }) { return { lang: 'python', source: '…' }; }, + // Optional: the reference page for what `run` emits, written when `client.docs` (or + // --docs) is on. Same `{ path, content }` shape as `run`; `renderReferencePage` gives + // the standard layout and takes `sample` for its snippets. A generator documents itself. + docs({ model, outputPath, emit }) { + return [{ path: outputPath.replace(/\.ts$/, '.mine.md'), content: '…' }]; + }, }; ``` @@ -97,6 +103,7 @@ discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, | `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). | diff --git a/packages/client-generator/eject-assets/skills/cli-docs-generator/SKILL.md b/packages/client-generator/eject-assets/skills/cli-docs-generator/SKILL.md deleted file mode 100644 index 16bf19ca8a..0000000000 --- a/packages/client-generator/eject-assets/skills/cli-docs-generator/SKILL.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -name: cli-docs-generator -description: Design of the ejected Redocly `cli-docs` client generator. Read it, and update it, before changing generators/cli-docs.mjs. ---- - -# The `cli-docs` generator — its skill - -This file is the DESIGN of your ejected `cli-docs` generator (`generators/cli-docs.mjs`): -**to change the generator, edit this skill first, then make the code match it** — a diff -to `generators/cli-docs.mjs` that has no covering sentence here is incomplete. - -## What it emits - -`.cli.md` — the Markdown reference for the generated CLI: the usage line, the -global flags, the credential environment variables, the exit-code table, and one section -per command with its positionals and flags (type, required, choices, description). - -## Design decisions that must hold - -- **One source of truth**: the page renders from `commandData(model, emit)` — the same - table `runCli` dispatches on — and from `groupSlug`/`envPrefix`, the same functions the - runtime addresses groups and reads credentials with. Documentation that derives from a - second model drifts from the tool the first time either side changes, so it never does - that. A new CLI capability shows up here only when it is in the command table. -- **Requires the `cli` generator** it documents: selecting `cli-docs` pulls in `cli` (and - through it `typescript` and `zod`), so `--generator cli-docs` is a complete, consistent set. -- **The renderer IS the template.** Publishers who need another structure eject this - generator rather than learning a template syntax — one customization mechanism, no - template engine, no new dependency. Light customization stays in declared options. -- **Declared options**: `title` (page heading, default ` CLI`) and - `frontmatter` (emit YAML front matter with the title, default `false`). Both are - validated by the pipeline before `run`, so the renderer reads them directly. -- **Markdown that survives a linter**: ATX headings, a blank line around every block, no - hard tabs, and one sentence per line in prose — generated docs land in repos that lint - Markdown in CI. -- **Escapes what descriptions contain**: a summary or description is arbitrary text, so - pipes are escaped inside table cells and newlines collapse to spaces. - -## Emitters that implement it - -`emitters/cli-docs.ts` (the page renderer), over `emitters/cli.ts`'s `commandData` and -the runtime's `groupSlug`/`envPrefix`. - -## Ejecting it - -`redocly eject-generator cli-docs` ships this generator BUNDLED with the emitter it uses — -one small `.mjs` you own, importing `@redocly/client-generator` and -`@redocly/openapi-core`. Change the sections, the wording, or the table columns, and -regenerate: this is the answer to "can the documentation templates be ejected too". - -## The modify loop - -1. Edit this skill: state the new behavior or decision. -2. Make `generators/cli-docs.mjs` match it. -3. Run `redocly generate-client` and inspect the `git diff` of the generated output — - generated files are never hand-edited. - -Newer built-in versions merge in with `redocly eject-generator cli-docs --update`. 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 2f7e99ebe9..57b68a340c 100644 --- a/packages/client-generator/eject-assets/skills/cli-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/cli-generator/SKILL.md @@ -14,6 +14,10 @@ to `generators/cli.mjs` that has no covering sentence here is incomplete. A bin-ready `.cli.ts`: one command per operation over the sdk's instance client, with `--help`, a `schema ` introspection command, and `--dry-run`. +With `client.docs` (or `--docs`), the `docs` hook also writes `.cli.md`: the usage +line, the global flags, the credential variables, the exit-code table, and one section per +command with its positionals and flags. + ## Design decisions that must hold - **Argument shape:** path params positional, query params typed `--kebab-name` flags, @@ -78,9 +82,18 @@ with `--help`, a `schema ` introspection command, and `--dry-run`. entry exports its `SOURCES` so an adopter layers custom commands around it without editing a generated file. Without `cliOutput`, nothing changes. +- **The CLI documents itself.** The page is this generator's `docs` hook, not a separate + generator: nothing else knows this tool's commands, and a reader who ejects `cli` gets + the page layout with it. The page renders from `commandData` — the same table `runCli` + dispatches on — so it cannot describe a tool other than the one beside it. A capability + reaches the page only by being in that table. The page is Markdown that survives a + linter (ATX headings, a blank line around every block, no hard tabs, one sentence per + line) and it escapes what descriptions contain, because a summary is arbitrary text. + ## Emitters that implement it -`emitters/cli.ts` (commands + module), plus the sdk's operation types. +`emitters/cli.ts` (commands + module) and `emitters/cli-docs.ts` (the page), plus the +sdk's operation types. ## Ejecting it 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 deab5cb79c..ef717aabf5 100644 --- a/packages/client-generator/eject-assets/skills/client-generators/SKILL.md +++ b/packages/client-generator/eject-assets/skills/client-generators/SKILL.md @@ -28,6 +28,12 @@ export default { sample(operation, { model, emit }) { return { lang: 'python', source: '…' }; }, + // Optional: the reference page for what `run` emits, written when `client.docs` (or + // --docs) is on. Same `{ path, content }` shape as `run`; `renderReferencePage` gives + // the standard layout and takes `sample` for its snippets. A generator documents itself. + docs({ model, outputPath, emit }) { + return [{ path: outputPath.replace(/\.ts$/, '.mine.md'), content: '…' }]; + }, }; ``` @@ -102,6 +108,7 @@ discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, | `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). | 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 d52c1cba8c..e6b701dd60 100644 --- a/packages/client-generator/eject-assets/skills/go-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/go-generator/SKILL.md @@ -71,6 +71,14 @@ runtime. Go ≥ 1.21, standard library only — zero dependencies. and embedded at prepare time. - Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise. +- **It documents itself.** With `client.docs` (or `--docs`), the `docs` hook writes + `.go.md`: the security schemes, then one section per operation with its parameters, + body, response type, and behavior notes. The call snippets come from this generator's own + `sample` hook, so the page can only show the syntax of the SDK beside it, and the layout + comes from `renderReferencePage` in the authoring toolkit — reachable from an ejected copy + through `@redocly/client-generator`. Pagination on the page is decided by + `paginationRuleFor`, the same helper this generator resolves pagination with. + ## The modify loop 1. Edit this skill: state the new behavior or decision. 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 955051041b..8863cb1fd7 100644 --- a/packages/client-generator/eject-assets/skills/php-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/php-generator/SKILL.md @@ -87,6 +87,14 @@ $idempotencyKey` on mutating methods. - Session/bearer token flows map to `auth: ['bearer' => $tokenProvider]` with a callable — resolved per request, so refresh needs no client rebuild. +- **It documents itself.** With `client.docs` (or `--docs`), the `docs` hook writes + `.php.md`: the security schemes, then one section per operation with its parameters, + body, response type, and behavior notes. The call snippets come from this generator's own + `sample` hook, so the page can only show the syntax of the SDK beside it, and the layout + comes from `renderReferencePage` in the authoring toolkit — reachable from an ejected copy + through `@redocly/client-generator`. Pagination on the page is decided by + `paginationRuleFor`, the same helper this generator resolves pagination with. + ## The modify loop 1. Edit this skill: state the new behavior or decision. 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 2b62b8618e..9f7db9d6c2 100644 --- a/packages/client-generator/eject-assets/skills/python-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/python-generator/SKILL.md @@ -60,6 +60,14 @@ One self-contained `.py`: typed dataclass models, a sync `Client` and an a - Authored ONLY with the neutral toolkit (`Printer`, naming, schema, pagination helpers) — the dogfooding guard fails otherwise. +- **It documents itself.** With `client.docs` (or `--docs`), the `docs` hook writes + `.python.md`: the security schemes, then one section per operation with its parameters, + body, response type, and behavior notes. The call snippets come from this generator's own + `sample` hook, so the page can only show the syntax of the SDK beside it, and the layout + comes from `renderReferencePage` in the authoring toolkit — reachable from an ejected copy + through `@redocly/client-generator`. Pagination on the page is decided by + `paginationRuleFor`, the same helper this generator resolves pagination with. + ## The modify loop 1. Edit this skill: state the new behavior or decision. diff --git a/packages/client-generator/eject-assets/skills/sdk-docs-generator/SKILL.md b/packages/client-generator/eject-assets/skills/sdk-docs-generator/SKILL.md deleted file mode 100644 index 467b01bdf1..0000000000 --- a/packages/client-generator/eject-assets/skills/sdk-docs-generator/SKILL.md +++ /dev/null @@ -1,75 +0,0 @@ ---- -name: sdk-docs-generator -description: Design of the ejected Redocly `sdk-docs` client generator. Read it, and update it, before changing generators/sdk-docs.mjs. ---- - -# The `sdk-docs` generator — its skill - -This file is the DESIGN of your ejected `sdk-docs` generator (`generators/sdk-docs.mjs`): -**to change the generator, edit this skill first, then make the code match it** — a diff -to `generators/sdk-docs.mjs` that has no covering sentence here is incomplete. - -## What it emits - -One Markdown page for each SDK generator selected in the same run: `.python.md`, -`.go.md`, `.php.md`, `.typescript.md`. A page carries the heading, the -requirements of that language, the security schemes the description declares, and one -section per operation: method and path, a call sample in that language, the parameters, -the request body, the response type, and the behavior notes (paginated, SSE, binary). - -## Design decisions that must hold - -- **No hand-written call syntax.** Every code block on the page comes from the SDK - generator's own `sample` hook — the same hook that produces `codeSamples`. This - generator never writes Python, Go, PHP, or TypeScript itself. A page that spelled out - call syntax would state the SDK a second time and would lie the first time the SDK - changed. -- **Pagination comes from the SDK's own resolver.** `paginationRuleFor` (the authoring - helper the `python`, `go`, and `php` generators resolve pagination with) decides the - note, so the page marks exactly the operations those SDKs paginate. The TypeScript - verifier, `resolveModelPagination`, would throw on a rule it cannot verify against the - response schema, and that would fail a python-only run over a page. -- **The hooks arrive as data.** The pipeline passes `samples` (the `sample` hook of every - selected generator, keyed by generator name) in `GeneratorInput`. Importing the language - generators instead would pull all four of them into this module and into the file - `eject-generator` produces. -- **It documents what is selected, and nothing else.** The pages come from - `selected ∩ {typescript, python, go, php}`. `requires` cannot express "one of these - four", so a selection with no SDK fails in `run` with the fix in the message. It never - pulls an SDK in: adding a language to someone's output because they asked for docs would - be a surprise, and it would emit a megabyte of SDK. -- **No fact is re-derived here.** The page does not name the SDK file, because each - language decides that name (`my-api.ts` becomes `my_api.py`). Parameters, bodies, - responses, and pagination come from the IR, which is what the SDKs are built from too. - What this generator knows by itself is one line per language: the label, the fence - language, and the runtime requirement. -- **Declared options**: `title` (page heading, default ` SDK -reference`) and `frontmatter` (YAML front matter carrying the title, default `false`). - With more than one SDK selected, a caller-supplied `title` gets the language appended, - because two pages must not share one heading. -- **The renderer IS the template.** Publishers who need another structure eject this - generator. No template syntax, no new dependency. -- **Markdown that survives a linter**: ATX headings, a blank line around every block, no - hard tabs, and one sentence per line in prose. -- **Escapes what descriptions contain**: a summary or description is arbitrary text, so - pipes are escaped inside table cells and newlines collapse to spaces. - -## Emitters that implement it - -`emitters/sdk-docs.ts` (the page renderer), over the IR and the `sample` hooks the -pipeline supplies. - -## Ejecting it - -`redocly eject-generator sdk-docs` ships this generator BUNDLED with its renderer — one -small `.mjs` you own, importing `@redocly/client-generator` and `@redocly/openapi-core`. -The language generators are not bundled with it, because the samples arrive as data. - -## The modify loop - -1. Edit this skill: state the new behavior or decision. -2. Make `generators/sdk-docs.mjs` match it. -3. Run `redocly generate-client` and inspect the `git diff` of the generated output — - generated files are never hand-edited. - -Newer built-in versions merge in with `redocly eject-generator sdk-docs --update`. diff --git a/packages/client-generator/eject-assets/skills/typescript-generator/SKILL.md b/packages/client-generator/eject-assets/skills/typescript-generator/SKILL.md index 9e105fc41d..9c29d1d400 100644 --- a/packages/client-generator/eject-assets/skills/typescript-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/typescript-generator/SKILL.md @@ -55,6 +55,14 @@ for the smaller paths first when they fit: `client.setup` bakes publisher defaul generated client, and middleware or `configure()` change behavior at run time rather than generation time. +- **It documents itself.** With `client.docs` (or `--docs`), the `docs` hook writes + `.typescript.md`: the security schemes, then one section per operation with its parameters, + body, response type, and behavior notes. The call snippets come from this generator's own + `sample` hook, so the page can only show the syntax of the SDK beside it, and the layout + comes from `renderReferencePage` in the authoring toolkit — reachable from an ejected copy + through `@redocly/client-generator`. Pagination on the page is decided by + `paginationRuleFor`, the same helper this generator resolves pagination with. + ## The modify loop 1. Edit this skill: state the new behavior or decision. diff --git a/packages/client-generator/scripts/generate-eject-assets.mjs b/packages/client-generator/scripts/generate-eject-assets.mjs index ed7023ba6a..2c4ef04b30 100644 --- a/packages/client-generator/scripts/generate-eject-assets.mjs +++ b/packages/client-generator/scripts/generate-eject-assets.mjs @@ -107,10 +107,13 @@ const BUILTIN_META = await loadBuiltinMeta(); * `notApplicable` — so an ejected generator still pulls its prerequisites in and is * validated exactly like the built-in it replaces. */ -function defaultExport(name, run, sample, options) { +function defaultExport(name, { run, sample, options, docs }) { const { load: _load, ...contract } = BUILTIN_META[name]; const fields = [` name: '${name}',`, ` run: ${run},`]; if (sample !== undefined) fields.push(` sample: ${sample},`); + // The generator's own reference page travels with it: an ejected copy keeps + // documenting itself, and the page layout is the user's to change. + if (docs !== undefined) fields.push(` docs: ${docs},`); if (options !== undefined) fields.push(` options: ${options},`); for (const [key, value] of Object.entries(contract)) { // Wrapped only when it would run long — the user owns and edits this file. @@ -142,9 +145,9 @@ function writeSkill(name) { } const LANGUAGE = [ - { name: 'python', run: 'pythonGenerator', sample: 'pythonSample' }, - { name: 'go', run: 'goGenerator', sample: 'goSample' }, - { name: 'php', run: 'phpGenerator', sample: 'phpSample' }, + { name: 'python', run: 'pythonGenerator', sample: 'pythonSample', docs: 'pythonDocs' }, + { name: 'go', run: 'goGenerator', sample: 'goSample', docs: 'goDocs' }, + { name: 'php', run: 'phpGenerator', sample: 'phpSample', docs: 'phpDocs' }, ]; /** @@ -155,26 +158,21 @@ const LANGUAGE = [ const TYPESCRIPT = [ { name: 'typescript', - imports: ['typescriptGenerator', 'typescriptSample'], + 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'], run: 'cliGenerator', sample: 'cliSample' }, { - name: 'cli-docs', - imports: ['cliDocsGenerator', 'cliDocsOptions'], - run: 'cliDocsGenerator', - options: 'cliDocsOptions', - }, - { - name: 'sdk-docs', - imports: ['sdkDocsGenerator', 'sdkDocsOptions'], - run: 'sdkDocsGenerator', - options: 'sdkDocsOptions', + name: 'cli', + imports: ['cliGenerator', 'cliSample', 'cliDocs'], + run: 'cliGenerator', + sample: 'cliSample', + docs: 'cliDocs', }, { name: 'tanstack-query', @@ -183,7 +181,7 @@ const TYPESCRIPT = [ }, ]; -for (const { name, imports, run, sample, options } of TYPESCRIPT) { +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. @@ -192,7 +190,7 @@ for (const { name, imports, run, sample, options } of TYPESCRIPT) { entry, `import { ${imports.join(', ')} } from ${JSON.stringify( join(pkgRoot, 'src', 'generators', name, 'index.ts') - )};\n` + defaultExport(name, run, sample, options) + )};\n` + defaultExport(name, { run, sample, options, docs }) ); const outFile = join(outDir, `${name}.mjs`); try { @@ -218,7 +216,7 @@ for (const { name, imports, run, sample, options } of TYPESCRIPT) { writeSkill(name); } -for (const { name, run, sample } of LANGUAGE) { +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( @@ -233,7 +231,10 @@ for (const { name, run, sample } of LANGUAGE) { }, }).outputText; const outFile = join(outDir, `${name}.mjs`); - writeFileSync(outFile, provenanceHeader(name) + stripped + defaultExport(name, run, sample)); + writeFileSync( + outFile, + provenanceHeader(name) + stripped + defaultExport(name, { run, sample, docs }) + ); checkSyntax(outFile, name); writeSkill(name); } diff --git a/packages/client-generator/src/authoring/index.ts b/packages/client-generator/src/authoring/index.ts index 97b2aa2dae..578a5cb57c 100644 --- a/packages/client-generator/src/authoring/index.ts +++ b/packages/client-generator/src/authoring/index.ts @@ -10,6 +10,13 @@ export { Printer } from './printer.js'; export type { DateType } from './options.js'; export { casing, identifierFor, RESERVED_WORDS } from './naming.js'; export { paginationRuleFor, type NeutralPaginationRule } from './pagination.js'; +// The Markdown reference page a generator's `docs` hook returns. Here rather than in the +// emitters, so a generator ejected as source reaches it through the package like we do. +export { + renderReferencePage, + type ReferenceLanguage, + type ReferencePageOptions, +} from './reference-page.js'; export { discriminatorCases, docText, @@ -36,5 +43,6 @@ export const AUTHORING_HELPER_NAMES = [ 'headerCoerceType', 'schemaAtPointer', 'paginationRuleFor', + 'renderReferencePage', 'NotSupportedError', ] as const; diff --git a/packages/client-generator/src/emitters/sdk-docs.ts b/packages/client-generator/src/authoring/reference-page.ts similarity index 86% rename from packages/client-generator/src/emitters/sdk-docs.ts rename to packages/client-generator/src/authoring/reference-page.ts index 39f32fd8ce..41827d4853 100644 --- a/packages/client-generator/src/emitters/sdk-docs.ts +++ b/packages/client-generator/src/authoring/reference-page.ts @@ -1,21 +1,20 @@ -// The sdk-docs emitter: renders the Markdown reference for one language SDK from the IR -// the SDK itself is built from, plus that generator's own `sample` hook for the call +// The reference-page renderer: the Markdown reference for ONE generated SDK, built from +// the IR that SDK is built from, plus that generator's own `sample` hook for the call // snippets. It writes no call syntax of its own — a second spelling of the SDK would -// drift from it the first time either side changed. +// drift from it the first time either side changed. Part of the authoring toolkit, so a +// generator ejected as source (python, go, php) reaches it the same way we do. -import { paginationRuleFor } from '../authoring/pagination.js'; -import { Printer } from '../authoring/printer.js'; -import type { CodeSample } from '../generators/types.js'; import type { ApiModel, OperationModel, ParamModel, SchemaModel, } from '../intermediate-representation/model.js'; -import type { PaginationConfig } from './pagination.js'; +import { paginationRuleFor } from './pagination.js'; +import { Printer } from './printer.js'; -/** What this generator knows about a language that the IR cannot tell it. */ -export type SdkDocsLanguage = { +/** What a generator knows about its language that the IR cannot tell the renderer. */ +export type ReferenceLanguage = { /** Generator name; also the infix of the page file (`.python.md`). */ name: string; /** Display name for the default heading. */ @@ -26,15 +25,16 @@ export type SdkDocsLanguage = { requires: string; }; -export type SdkDocsOptions = { +export type ReferencePageOptions = { /** Page heading. */ title: string; /** Emit YAML front matter carrying the title, for docs sites that expect it. */ frontmatter: boolean; - language: SdkDocsLanguage; - /** The call snippet for one operation, from the SDK generator's own `sample` hook. */ - sample: (operation: OperationModel) => CodeSample | undefined; - pagination?: PaginationConfig; + language: ReferenceLanguage; + /** The call snippet for one operation — the generator's own `sample` hook. */ + sample: (operation: OperationModel) => { lang: string; source: string } | undefined; + /** The `pagination` config, passed through to `paginationRuleFor`. */ + pagination?: Record; }; /** Table-cell-safe text: one line, with pipes and backslashes escaped. */ @@ -100,7 +100,7 @@ function writeParameterTable(printer: Printer, params: ParamModel[]): void { printer.blank(); } -function writeOperation(printer: Printer, op: OperationModel, options: SdkDocsOptions): void { +function writeOperation(printer: Printer, op: OperationModel, options: ReferencePageOptions): void { printer.line(`### \`${op.specName ?? op.name}\``); printer.blank(); if (op.summary !== undefined) { @@ -135,7 +135,7 @@ function writeOperation(printer: Printer, op: OperationModel, options: SdkDocsOp // 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 as Record | undefined)) { + if (paginationRuleFor(op, options.pagination)) { printer.line('This operation is paginated, so the SDK gives it page and item iterators.'); } if (op.successResponses.some((response) => response.contentType === 'text/event-stream')) { @@ -148,7 +148,7 @@ function writeOperation(printer: Printer, op: OperationModel, options: SdkDocsOp } /** The whole page: heading, requirements, security schemes, then every operation by tag. */ -export function renderSdkDocs(model: ApiModel, options: SdkDocsOptions): string { +export function renderReferencePage(model: ApiModel, options: ReferencePageOptions): string { const printer = new Printer(); if (options.frontmatter) { printer.line('---'); diff --git a/packages/client-generator/src/emitters/emit-options.ts b/packages/client-generator/src/emitters/emit-options.ts index 38e7d93cb3..19d1ad2e53 100644 --- a/packages/client-generator/src/emitters/emit-options.ts +++ b/packages/client-generator/src/emitters/emit-options.ts @@ -76,6 +76,14 @@ export type EmitOptions = { * statically: an explicit rule that doesn't fit its operation fails generation. */ pagination?: PaginationConfig; + /** + * Also write the reference documentation for what each selected generator emits: one + * Markdown page per generator that implements the `docs` hook. One switch for the whole + * run, so a new documented language never needs a new flag. + */ + docs?: boolean; + /** Emit YAML front matter carrying the title above each documentation page. */ + docsFrontmatter?: boolean; }; /** diff --git a/packages/client-generator/src/generators/__tests__/cli-docs.test.ts b/packages/client-generator/src/generators/__tests__/cli-docs.test.ts deleted file mode 100644 index 1ee20754dc..0000000000 --- a/packages/client-generator/src/generators/__tests__/cli-docs.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { - modelWith, - namedSchema, - operation, - param, - response, -} from '../../emitters/__tests__/fixtures.js'; -import { cliDocsGenerator } from '../cli-docs/index.js'; - -const CAFE = modelWith( - [ - operation({ - name: 'listOrders', - method: 'get', - path: '/orders', - tags: ['Coffee Orders'], - summary: 'List orders\nacross every shop', - queryParams: [ - param('status', 'query', false, { - kind: 'enum', - scalar: 'string', - values: ['open', 'closed'], - }), - param('maxTotal', 'query', false, { kind: 'scalar', scalar: 'number' }), - ], - successResponses: [response({ schema: { kind: 'ref', name: 'Order' } })], - }), - operation({ - name: 'createOrder', - method: 'post', - path: '/orders', - tags: ['Coffee Orders'], - summary: 'Create an order | with a pipe', - requestBody: { - contentType: 'application/json', - required: true, - schema: { kind: 'ref', name: 'Order' }, - }, - successResponses: [response({ status: 201, schema: { kind: 'ref', name: 'Order' } })], - }), - operation({ - name: 'uploadPhoto', - method: 'post', - path: '/menu/{id}/photo', - tags: ['Coffee Orders'], - requestBody: { - contentType: 'multipart/form-data', - required: true, - schema: { kind: 'unknown' }, - }, - }), - operation({ name: 'ping', method: 'get', path: '/ping' }), - ], - { - title: 'Cafe API', - schemas: [namedSchema('Order', { kind: 'object', properties: [] })], - securitySchemes: [{ key: 'BearerAuth', kind: 'bearer' }], - } -); - -function render(options: Record = {}): string { - const files = cliDocsGenerator({ - model: CAFE, - outputPath: '/out/cafe.client.ts', - outputMode: 'single', - emit: {}, - selected: ['typescript', 'zod', 'cli', 'cli-docs'], - options, - }); - expect(files).toHaveLength(1); - expect(files[0].path).toBe('/out/cafe.client.cli.md'); - return files[0].content; -} - -describe('cliDocsGenerator', () => { - it('documents every command the CLI dispatches, addressed the way the CLI addresses it', () => { - const page = render(); - // Groups are the slugs the CLI accepts, with the original tag as the section title. - expect(page).toContain('## Coffee Orders'); - expect(page).toContain('### `coffee-orders listOrders`'); - expect(page).toContain('### `coffee-orders createOrder`'); - // An untagged operation is addressed without a group. - expect(page).toContain('### `ping`'); - expect(page).toContain('GET /orders'); - }); - - it('renders flags with type, requiredness, and choices', () => { - const page = render(); - expect(page).toContain('--status'); - expect(page).toContain('`open`, `closed`'); - // A number-typed query param is documented as a number, not a string. - expect(page).toMatch(/--max-total.*number/); - expect(page).toContain('--json'); - }); - - it('carries the global flags, the credential variables, and the exit-code contract', () => { - const page = render(); - expect(page).toContain('--page-all'); - // The env prefix comes from the bin name the CLI derives from the same stem. - expect(page).toContain('CAFE_CLIENT_TOKEN'); - expect(page).toContain('| 3 |'); - expect(page).toContain('validation error'); - }); - - it('lists --token only when the description declares a bearer scheme, like the CLI itself', () => { - expect(render()).toContain('--token'); - const noBearer = modelWith([operation({ name: 'ping', method: 'get', path: '/ping' })], { - title: 'Cafe API', - securitySchemes: [{ kind: 'apiKeyHeader', key: 'ApiKeyAuth', headerName: 'X-Api-Key' }], - }); - const files = cliDocsGenerator({ - model: noBearer, - outputPath: '/out/cafe.client.ts', - outputMode: 'single', - emit: {}, - selected: ['typescript', 'zod', 'cli', 'cli-docs'], - options: {}, - }); - expect(files[0].content).not.toContain('--token'); - }); - - it('says when a body is one the CLI cannot build, instead of implying the command runs', () => { - const page = render(); - expect(page).toContain('### `coffee-orders uploadPhoto`'); - expect(page).toContain('`multipart/form-data` body, which the CLI cannot build'); - // And it does not advertise --json for that command. - const section = page.slice(page.indexOf('### `coffee-orders uploadPhoto`')); - expect(section.slice(0, section.indexOf('###', 3))).not.toContain('--json'); - }); - - it('keeps a description safe inside a table cell', () => { - const page = render(); - // A newline would break the row; a pipe would open a new column. - expect(page).toContain('List orders across every shop'); - expect(page).toContain('Create an order \\| with a pipe'); - }); - - it('honors its declared options', () => { - expect(render()).toContain('# Cafe API command-line reference'); - const custom = render({ title: 'Coffee CLI', frontmatter: true }); - expect(custom.startsWith('---\ntitle: Coffee CLI\n---\n')).toBe(true); - expect(custom).toContain('# Coffee CLI'); - }); -}); 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 688e0f9494..f763aac2fe 100644 --- a/packages/client-generator/src/generators/__tests__/generator-skills.test.ts +++ b/packages/client-generator/src/generators/__tests__/generator-skills.test.ts @@ -14,16 +14,7 @@ const generatorsDir = resolve(dirname(fileURLToPath(import.meta.url)), '..'); /** Language generators: one self-contained file, ejected as its own source. */ const LANGUAGE = ['python', 'go', 'php']; /** TypeScript generators: thin entries over shared emitters, ejected bundled with them. */ -const TYPESCRIPT = [ - 'typescript', - 'zod', - 'mock', - 'cli', - 'cli-docs', - 'swr', - 'tanstack-query', - 'transformers', -]; +const TYPESCRIPT = ['typescript', 'zod', 'mock', 'cli', 'swr', 'tanstack-query', 'transformers']; const EJECTABLE = [...LANGUAGE, ...TYPESCRIPT]; describe.each(EJECTABLE)('%s generator skill', (name) => { diff --git a/packages/client-generator/src/generators/cli-docs/AGENTS.md b/packages/client-generator/src/generators/cli-docs/AGENTS.md deleted file mode 100644 index 79ff0daf5a..0000000000 --- a/packages/client-generator/src/generators/cli-docs/AGENTS.md +++ /dev/null @@ -1,55 +0,0 @@ -# The `cli-docs` generator — its skill - -This file is the generator's DESIGN and governs our own changes: **to change the -generator, edit this skill first, then make the code match it.** - -`npm run prepare` compiles it into `eject-assets/skills/cli-docs-generator/SKILL.md`, -the copy that ships to users — that asset is generated, so never edit it by hand. - -## What it emits - -`.cli.md` — the Markdown reference for the generated CLI: the usage line, the -global flags, the credential environment variables, the exit-code table, and one section -per command with its positionals and flags (type, required, choices, description). - -## Design decisions that must hold - -- **One source of truth**: the page renders from `commandData(model, emit)` — the same - table `runCli` dispatches on — and from `groupSlug`/`envPrefix`, the same functions the - runtime addresses groups and reads credentials with. Documentation that derives from a - second model drifts from the tool the first time either side changes, so it never does - that. A new CLI capability shows up here only when it is in the command table. -- **Requires the `cli` generator** it documents: selecting `cli-docs` pulls in `cli` (and - through it `typescript` and `zod`), so `--generator cli-docs` is a complete, consistent set. -- **The renderer IS the template.** Publishers who need another structure eject this - generator rather than learning a template syntax — one customization mechanism, no - template engine, no new dependency. Light customization stays in declared options. -- **Declared options**: `title` (page heading, default ` CLI`) and - `frontmatter` (emit YAML front matter with the title, default `false`). Both are - validated by the pipeline before `run`, so the renderer reads them directly. -- **Markdown that survives a linter**: ATX headings, a blank line around every block, no - hard tabs, and one sentence per line in prose — generated docs land in repos that lint - Markdown in CI. -- **Escapes what descriptions contain**: a summary or description is arbitrary text, so - pipes are escaped inside table cells and newlines collapse to spaces. - -## Emitters that implement it - -`emitters/cli-docs.ts` (the page renderer), over `emitters/cli.ts`'s `commandData` and -the runtime's `groupSlug`/`envPrefix`. - -## Ejecting it - -`redocly eject-generator cli-docs` ships this generator BUNDLED with the emitter it uses — -one small `.mjs` you own, importing `@redocly/client-generator` and -`@redocly/openapi-core`. Change the sections, the wording, or the table columns, and -regenerate: this is the answer to "can the documentation templates be ejected too". - -## The modify loop - -1. Edit this skill: state the new behavior or decision. -2. Change the emitter modules named above (the entry is plumbing — it rarely moves). -3. Verify: `npm run compile`, the emitter unit suites - (`VITEST_SUITE=unit npx vitest run packages/client-generator/src/emitters`), the cli - e2e suites, and the large-description bars - (`tests/e2e/generate-client/large-descriptions.test.ts`). diff --git a/packages/client-generator/src/generators/cli-docs/index.ts b/packages/client-generator/src/generators/cli-docs/index.ts deleted file mode 100644 index 480506a47f..0000000000 --- a/packages/client-generator/src/generators/cli-docs/index.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { join } from 'node:path'; - -import { renderCliDocs } from '../../emitters/cli-docs.js'; -import { cliAuthSchemes, commandData } from '../../emitters/cli.js'; -import { anchor } from '../anchor.js'; -import type { Generator, GeneratorOptionsSchema } from '../types.js'; - -/** - * The cli-docs generator: `.cli.md`, the Markdown reference for the generated CLI — - * usage, global flags, credential variables, exit codes, and every command with its - * positionals and flags. It renders from the same command table the CLI dispatches on, so - * the page cannot drift from the tool it documents. - */ -export const cliDocsOptions: GeneratorOptionsSchema = { - type: 'object', - properties: { - title: { - type: 'string', - description: 'Page heading. Defaults to " command-line reference".', - }, - frontmatter: { - type: 'boolean', - default: false, - description: 'Emit YAML front matter carrying the title, for docs sites that expect it.', - }, - }, - additionalProperties: false, -}; - -/** The stem as a command name — the same fold the cli generator applies. */ -function commandName(stem: string): string { - return ( - stem - .replace(/[^A-Za-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') - .toLowerCase() || 'client' - ); -} - -export const cliDocsGenerator: Generator = ({ model, outputPath, emit, options }) => { - const { dir, stem } = anchor(outputPath); - const content = renderCliDocs(commandData(model, { pagination: emit.pagination }), { - title: (options?.title as string | undefined) ?? `${model.title} command-line reference`, - frontmatter: options?.frontmatter === true, - binName: emit.binName ?? commandName(stem), - schemes: cliAuthSchemes(model), - }); - return [{ path: join(dir, `${stem}.cli.md`), content }]; -}; diff --git a/packages/client-generator/src/generators/cli/AGENTS.md b/packages/client-generator/src/generators/cli/AGENTS.md index d8bca2cdcb..7494f533c4 100644 --- a/packages/client-generator/src/generators/cli/AGENTS.md +++ b/packages/client-generator/src/generators/cli/AGENTS.md @@ -11,6 +11,10 @@ the copy that ships to users — that asset is generated, so never edit it by ha A bin-ready `.cli.ts`: one command per operation over the sdk's instance client, with `--help`, a `schema ` introspection command, and `--dry-run`. +With `client.docs` (or `--docs`), the `docs` hook also writes `.cli.md`: the usage +line, the global flags, the credential variables, the exit-code table, and one section per +command with its positionals and flags. + ## Design decisions that must hold - **Argument shape:** path params positional, query params typed `--kebab-name` flags, @@ -75,9 +79,18 @@ with `--help`, a `schema ` introspection command, and `--dry-run`. entry exports its `SOURCES` so an adopter layers custom commands around it without editing a generated file. Without `cliOutput`, nothing changes. +- **The CLI documents itself.** The page is this generator's `docs` hook, not a separate + generator: nothing else knows this tool's commands, and a reader who ejects `cli` gets + the page layout with it. The page renders from `commandData` — the same table `runCli` + dispatches on — so it cannot describe a tool other than the one beside it. A capability + reaches the page only by being in that table. The page is Markdown that survives a + linter (ATX headings, a blank line around every block, no hard tabs, one sentence per + line) and it escapes what descriptions contain, because a summary is arbitrary text. + ## Emitters that implement it -`emitters/cli.ts` (commands + module), plus the sdk's operation types. +`emitters/cli.ts` (commands + module) and `emitters/cli-docs.ts` (the page), plus the +sdk's operation types. ## Ejecting it diff --git a/packages/client-generator/src/generators/cli/index.ts b/packages/client-generator/src/generators/cli/index.ts index 07dc2f9afe..5fa39db834 100644 --- a/packages/client-generator/src/generators/cli/index.ts +++ b/packages/client-generator/src/generators/cli/index.ts @@ -1,6 +1,7 @@ import { join } from 'node:path'; -import { commandData, renderCliModule } from '../../emitters/cli.js'; +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'; @@ -36,6 +37,23 @@ export const cliGenerator: Generator = ({ model, outputPath, emit, selected }) = return [{ path: join(dir, `${stem}.cli.ts`), content }]; }; +/** + * The CLI's own reference page, written when `client.docs` is on: the usage line, the + * global flags, the credential variables, the exit codes, and one section per command. + * It renders from `commandData` — the same table `runCli` dispatches on — so the page + * cannot describe a tool other than the one beside it. + */ +export const cliDocs: Generator = ({ model, outputPath, emit }) => { + const { dir, stem } = anchor(outputPath); + const content = renderCliDocs(commandData(model, { pagination: emit.pagination }), { + title: `${model.title} command-line reference`, + frontmatter: emit.docsFrontmatter === true, + binName: emit.binName ?? commandName(stem), + schemes: cliAuthSchemes(model), + }); + return [{ path: join(dir, `${stem}.cli.md`), content }]; +}; + /** One shell invocation per operation — feeds `x-codeSamples` for docs. */ export function cliSample(op: OperationModel, ctx: SampleContext): CodeSample | undefined { const command = commandData(ctx.model, { pagination: ctx.emit.pagination }).find( diff --git a/packages/client-generator/src/generators/go/AGENTS.md b/packages/client-generator/src/generators/go/AGENTS.md index f00e612eda..315a958017 100644 --- a/packages/client-generator/src/generators/go/AGENTS.md +++ b/packages/client-generator/src/generators/go/AGENTS.md @@ -70,6 +70,14 @@ runtime. Go ≥ 1.21, standard library only — zero dependencies. and embedded at prepare time. - Authored ONLY with the neutral toolkit — the dogfooding guard fails otherwise. +- **It documents itself.** With `client.docs` (or `--docs`), the `docs` hook writes + `.go.md`: the security schemes, then one section per operation with its parameters, + body, response type, and behavior notes. The call snippets come from this generator's own + `sample` hook, so the page can only show the syntax of the SDK beside it, and the layout + comes from `renderReferencePage` in the authoring toolkit — reachable from an ejected copy + through `@redocly/client-generator`. Pagination on the page is decided by + `paginationRuleFor`, the same helper this generator resolves pagination with. + ## The modify loop 1. Edit this skill: state the new behavior or decision. diff --git a/packages/client-generator/src/generators/go/index.ts b/packages/client-generator/src/generators/go/index.ts index 260b6d08c8..682122eacd 100644 --- a/packages/client-generator/src/generators/go/index.ts +++ b/packages/client-generator/src/generators/go/index.ts @@ -16,6 +16,7 @@ import { isNullable, NotSupportedError, paginationRuleFor, + renderReferencePage, RESERVED_WORDS, schemaAtPointer, unwrapNullable, @@ -1120,3 +1121,26 @@ export function goSample(op: OperationModel, ctx: SampleContext): CodeSample { source: `client := client.New(client.Config{})\nresult, err := client.${ident}(${args.join(', ')})\n`, }; } + +/** + * The SDK's own reference page, written when `client.docs` is on. The call snippets come + * from `goSample` — this generator's own hook — so the page can only ever show the syntax + * of the SDK beside it, and ejecting this generator takes the page with it. + */ +export const goDocs: Generator = ({ model, outputPath, emit }) => [ + { + path: outputPath.replace(/\.[^.\\/]+$/, '.go.md'), + content: renderReferencePage(model, { + title: `${model.title} Go SDK reference`, + frontmatter: emit.docsFrontmatter === true, + language: { + name: 'go', + label: 'Go', + fence: 'go', + requires: 'The SDK needs the standard library only.', + }, + sample: (op) => goSample(op, { model, emit }), + pagination: emit.pagination, + }), + }, +]; diff --git a/packages/client-generator/src/generators/index.ts b/packages/client-generator/src/generators/index.ts index a72f419fb7..c101c6fec7 100644 --- a/packages/client-generator/src/generators/index.ts +++ b/packages/client-generator/src/generators/index.ts @@ -1,17 +1,15 @@ import type { EmitOptions } from '../emitters/emit-options.js'; -import { cliDocsGenerator } from './cli-docs/index.js'; -import { cliGenerator, cliSample } from './cli/index.js'; -import { goGenerator, goSample } from './go/index.js'; +import { cliDocs, cliGenerator, cliSample } from './cli/index.js'; +import { goDocs, goGenerator, goSample } from './go/index.js'; import { BUILTIN_META, validateSelection, type BuiltinMeta } from './meta.js'; import { mockGenerator } from './mock/index.js'; -import { phpGenerator, phpSample } from './php/index.js'; -import { pythonGenerator, pythonSample } from './python/index.js'; -import { sdkDocsGenerator } from './sdk-docs/index.js'; +import { phpDocs, phpGenerator, phpSample } from './php/index.js'; +import { pythonDocs, pythonGenerator, pythonSample } from './python/index.js'; import { swrGenerator } from './swr/index.js'; import { tanstackQueryGenerator } from './tanstack-query/index.js'; import { transformersGenerator } from './transformers/index.js'; import type { GeneratorDescriptor, GeneratorName, OutputMode } from './types.js'; -import { typescriptGenerator, typescriptSample } from './typescript/index.js'; +import { typescriptDocs, typescriptGenerator, typescriptSample } from './typescript/index.js'; import { zodGenerator } from './zod/index.js'; export type { @@ -26,8 +24,8 @@ export type { // statically anyway). Compatibility metadata lives in BUILTIN_META — one home; // only the eagerly imported `run` functions live here. The pipeline entry never // touches this module: it loads built-ins lazily through the meta table. -const RUNS: Record> = { - typescript: { run: typescriptGenerator, sample: typescriptSample }, +const RUNS: Record> = { + typescript: { run: typescriptGenerator, sample: typescriptSample, docs: typescriptDocs }, zod: { run: zodGenerator }, transformers: { run: transformersGenerator }, 'tanstack-query': { run: tanstackQueryGenerator('react') }, @@ -36,12 +34,10 @@ const RUNS: Record> = 'tanstack-query-solid': { run: tanstackQueryGenerator('solid') }, swr: { run: swrGenerator }, mock: { run: mockGenerator }, - cli: { run: cliGenerator, sample: cliSample }, - 'cli-docs': { run: cliDocsGenerator }, - 'sdk-docs': { run: sdkDocsGenerator }, - python: { run: pythonGenerator, sample: pythonSample }, - go: { run: goGenerator, sample: goSample }, - php: { run: phpGenerator, sample: phpSample }, + cli: { run: cliGenerator, sample: cliSample, docs: cliDocs }, + python: { run: pythonGenerator, sample: pythonSample, docs: pythonDocs }, + go: { run: goGenerator, sample: goSample, docs: goDocs }, + php: { run: phpGenerator, sample: phpSample, docs: phpDocs }, }; const GENERATORS = Object.fromEntries( diff --git a/packages/client-generator/src/generators/meta.ts b/packages/client-generator/src/generators/meta.ts index b6dbd25226..5e318b270f 100644 --- a/packages/client-generator/src/generators/meta.ts +++ b/packages/client-generator/src/generators/meta.ts @@ -9,8 +9,8 @@ import type { EmitOptions } from '../emitters/emit-options.js'; import { NotSupportedError } from '../errors.js'; import type { GeneratorDescriptor, GeneratorName, OutputMode } from './types.js'; -export type BuiltinMeta = Omit & { - load: () => Promise>; +export type BuiltinMeta = Omit & { + load: () => Promise>; }; function tanstackQuery(framework: 'react' | 'vue' | 'svelte' | 'solid'): BuiltinMeta { @@ -43,6 +43,7 @@ export const BUILTIN_META: Record = { import('./typescript/index.js').then((m) => ({ run: m.typescriptGenerator, sample: m.typescriptSample, + docs: m.typescriptDocs, })), }, zod: { load: () => import('./zod/index.js').then((m) => ({ run: m.zodGenerator })) }, @@ -80,37 +81,10 @@ export const BUILTIN_META: Record = { requires: ['typescript', 'zod'], errorModes: ['throw'], load: () => - import('./cli/index.js').then((m) => ({ run: m.cliGenerator, sample: m.cliSample })), - }, - // cli-docs renders the Markdown reference for the CLI from the same command table the - // CLI dispatches on, so it requires the generator it documents. - 'cli-docs': { - requires: ['cli'], - errorModes: ['throw'], - notApplicable: { - outputMode: 'it emits one Markdown page', - importExt: 'a Markdown page has no imports', - runtime: 'a Markdown page embeds no runtime', - }, - load: () => - import('./cli-docs/index.js').then((m) => ({ - run: m.cliDocsGenerator, - options: m.cliDocsOptions, - })), - }, - // sdk-docs renders one Markdown page per SDK selected beside it. It requires nothing: - // `requires` cannot say "one of typescript, python, go, php", and pulling an SDK in - // because docs were asked for would emit a whole SDK nobody selected. - 'sdk-docs': { - notApplicable: { - outputMode: 'it emits one Markdown page per SDK', - importExt: 'a Markdown page has no imports', - runtime: 'a Markdown page embeds no runtime', - }, - load: () => - import('./sdk-docs/index.js').then((m) => ({ - run: m.sdkDocsGenerator, - options: m.sdkDocsOptions, + import('./cli/index.js').then((m) => ({ + run: m.cliGenerator, + sample: m.cliSample, + docs: m.cliDocs, })), }, // python emits a standalone full Python SDK (httpx) — no TypeScript involved, @@ -118,14 +92,23 @@ export const BUILTIN_META: Record = { python: { notApplicable: LANGUAGE_SDK_NOT_APPLICABLE, load: () => - import('./python/index.js').then((m) => ({ run: m.pythonGenerator, sample: m.pythonSample })), + import('./python/index.js').then((m) => ({ + run: m.pythonGenerator, + sample: m.pythonSample, + docs: m.pythonDocs, + })), }, // go emits a standalone full Go SDK (stdlib-only) — no TypeScript involved. // `(T, error)` returns ARE its error mode, so `result` has no Go rendering. go: { errorModes: ['throw'], notApplicable: LANGUAGE_SDK_NOT_APPLICABLE, - load: () => import('./go/index.js').then((m) => ({ run: m.goGenerator, sample: m.goSample })), + load: () => + import('./go/index.js').then((m) => ({ + run: m.goGenerator, + sample: m.goSample, + docs: m.goDocs, + })), }, // php emits a standalone full PHP SDK (curl extension) — no TypeScript involved. // Exceptions ARE its error mode, so `result` has no PHP rendering. @@ -133,7 +116,11 @@ export const BUILTIN_META: Record = { errorModes: ['throw'], notApplicable: LANGUAGE_SDK_NOT_APPLICABLE, load: () => - import('./php/index.js').then((m) => ({ run: m.phpGenerator, sample: m.phpSample })), + import('./php/index.js').then((m) => ({ + run: m.phpGenerator, + sample: m.phpSample, + docs: m.phpDocs, + })), }, }; @@ -143,7 +130,7 @@ const SINGLE_GENERATOR_OPTIONS: { generators: GeneratorName[]; reason: string; }[] = [ - { option: 'binName', generators: ['cli', 'cli-docs'], reason: 'it names the generated command' }, + { option: 'binName', generators: ['cli'], reason: 'it names the generated command' }, { option: 'goPackage', generators: ['go'], reason: 'it declares the Go package clause' }, ]; diff --git a/packages/client-generator/src/generators/php/AGENTS.md b/packages/client-generator/src/generators/php/AGENTS.md index c12e5552a9..c9f589f494 100644 --- a/packages/client-generator/src/generators/php/AGENTS.md +++ b/packages/client-generator/src/generators/php/AGENTS.md @@ -86,6 +86,14 @@ $idempotencyKey` on mutating methods. - Session/bearer token flows map to `auth: ['bearer' => $tokenProvider]` with a callable — resolved per request, so refresh needs no client rebuild. +- **It documents itself.** With `client.docs` (or `--docs`), the `docs` hook writes + `.php.md`: the security schemes, then one section per operation with its parameters, + body, response type, and behavior notes. The call snippets come from this generator's own + `sample` hook, so the page can only show the syntax of the SDK beside it, and the layout + comes from `renderReferencePage` in the authoring toolkit — reachable from an ejected copy + through `@redocly/client-generator`. Pagination on the page is decided by + `paginationRuleFor`, the same helper this generator resolves pagination with. + ## The modify loop 1. Edit this skill: state the new behavior or decision. diff --git a/packages/client-generator/src/generators/php/index.ts b/packages/client-generator/src/generators/php/index.ts index 489ec5406b..aa4c538176 100644 --- a/packages/client-generator/src/generators/php/index.ts +++ b/packages/client-generator/src/generators/php/index.ts @@ -15,6 +15,7 @@ import { identifierFor, isNullable, paginationRuleFor, + renderReferencePage, RESERVED_WORDS, schemaAtPointer, unwrapNullable, @@ -1048,3 +1049,26 @@ export function phpSample(op: OperationModel, ctx: SampleContext): CodeSample { source: `use ${namespace}\\{Client, Config};\n\n$client = new Client(new Config());\n$result = $client->${methodName(op)}(${args.join(', ')});\n`, }; } + +/** + * The SDK's own reference page, written when `client.docs` is on. The call snippets come + * from `phpSample` — this generator's own hook — so the page can only ever show the syntax + * of the SDK beside it, and ejecting this generator takes the page with it. + */ +export const phpDocs: Generator = ({ model, outputPath, emit }) => [ + { + path: outputPath.replace(/\.[^.\\/]+$/, '.php.md'), + content: renderReferencePage(model, { + title: `${model.title} PHP SDK reference`, + frontmatter: emit.docsFrontmatter === true, + language: { + name: 'php', + label: 'PHP', + fence: 'php', + requires: 'The SDK needs the curl extension.', + }, + sample: (op) => phpSample(op, { model, emit }), + pagination: emit.pagination, + }), + }, +]; diff --git a/packages/client-generator/src/generators/python/AGENTS.md b/packages/client-generator/src/generators/python/AGENTS.md index 8c11059b52..12600b16e3 100644 --- a/packages/client-generator/src/generators/python/AGENTS.md +++ b/packages/client-generator/src/generators/python/AGENTS.md @@ -59,6 +59,14 @@ One self-contained `.py`: typed dataclass models, a sync `Client` and an a - Authored ONLY with the neutral toolkit (`Printer`, naming, schema, pagination helpers) — the dogfooding guard fails otherwise. +- **It documents itself.** With `client.docs` (or `--docs`), the `docs` hook writes + `.python.md`: the security schemes, then one section per operation with its parameters, + body, response type, and behavior notes. The call snippets come from this generator's own + `sample` hook, so the page can only show the syntax of the SDK beside it, and the layout + comes from `renderReferencePage` in the authoring toolkit — reachable from an ejected copy + through `@redocly/client-generator`. Pagination on the page is decided by + `paginationRuleFor`, the same helper this generator resolves pagination with. + ## The modify loop 1. Edit this skill: state the new behavior or decision. diff --git a/packages/client-generator/src/generators/python/index.ts b/packages/client-generator/src/generators/python/index.ts index affa9a6e1a..786f9ef9b7 100644 --- a/packages/client-generator/src/generators/python/index.ts +++ b/packages/client-generator/src/generators/python/index.ts @@ -6,6 +6,7 @@ import { Printer, paginationRuleFor, + renderReferencePage, schemaAtPointer, discriminatorCases, docText, @@ -769,3 +770,26 @@ export function pythonSample(op: OperationModel, _ctx: SampleContext): CodeSampl source: `from client import Client\n\nclient = Client()\nresult = client.${ident}(${args.join(', ')})\n`, }; } + +/** + * The SDK's own reference page, written when `client.docs` is on. The call snippets come + * from `pythonSample` — this generator's own hook — so the page can only ever show the syntax + * of the SDK beside it, and ejecting this generator takes the page with it. + */ +export const pythonDocs: Generator = ({ model, outputPath, emit }) => [ + { + path: outputPath.replace(/\.[^.\\/]+$/, '.python.md'), + content: renderReferencePage(model, { + title: `${model.title} Python SDK reference`, + frontmatter: emit.docsFrontmatter === true, + language: { + name: 'python', + label: 'Python', + fence: 'python', + requires: 'The SDK needs `httpx`.', + }, + sample: (op) => pythonSample(op, { model, emit }), + pagination: emit.pagination, + }), + }, +]; diff --git a/packages/client-generator/src/generators/sdk-docs/AGENTS.md b/packages/client-generator/src/generators/sdk-docs/AGENTS.md deleted file mode 100644 index adc0481866..0000000000 --- a/packages/client-generator/src/generators/sdk-docs/AGENTS.md +++ /dev/null @@ -1,71 +0,0 @@ -# The `sdk-docs` generator — its skill - -This file is the generator's DESIGN and governs our own changes: **to change the -generator, edit this skill first, then make the code match it.** - -`npm run prepare` compiles it into `eject-assets/skills/sdk-docs-generator/SKILL.md`, -the copy that ships to users — that asset is generated, so never edit it by hand. - -## What it emits - -One Markdown page for each SDK generator selected in the same run: `.python.md`, -`.go.md`, `.php.md`, `.typescript.md`. A page carries the heading, the -requirements of that language, the security schemes the description declares, and one -section per operation: method and path, a call sample in that language, the parameters, -the request body, the response type, and the behavior notes (paginated, SSE, binary). - -## Design decisions that must hold - -- **No hand-written call syntax.** Every code block on the page comes from the SDK - generator's own `sample` hook — the same hook that produces `codeSamples`. This - generator never writes Python, Go, PHP, or TypeScript itself. A page that spelled out - call syntax would state the SDK a second time and would lie the first time the SDK - changed. -- **Pagination comes from the SDK's own resolver.** `paginationRuleFor` (the authoring - helper the `python`, `go`, and `php` generators resolve pagination with) decides the - note, so the page marks exactly the operations those SDKs paginate. The TypeScript - verifier, `resolveModelPagination`, would throw on a rule it cannot verify against the - response schema, and that would fail a python-only run over a page. -- **The hooks arrive as data.** The pipeline passes `samples` (the `sample` hook of every - selected generator, keyed by generator name) in `GeneratorInput`. Importing the language - generators instead would pull all four of them into this module and into the file - `eject-generator` produces. -- **It documents what is selected, and nothing else.** The pages come from - `selected ∩ {typescript, python, go, php}`. `requires` cannot express "one of these - four", so a selection with no SDK fails in `run` with the fix in the message. It never - pulls an SDK in: adding a language to someone's output because they asked for docs would - be a surprise, and it would emit a megabyte of SDK. -- **No fact is re-derived here.** The page does not name the SDK file, because each - language decides that name (`my-api.ts` becomes `my_api.py`). Parameters, bodies, - responses, and pagination come from the IR, which is what the SDKs are built from too. - What this generator knows by itself is one line per language: the label, the fence - language, and the runtime requirement. -- **Declared options**: `title` (page heading, default ` SDK -reference`) and `frontmatter` (YAML front matter carrying the title, default `false`). - With more than one SDK selected, a caller-supplied `title` gets the language appended, - because two pages must not share one heading. -- **The renderer IS the template.** Publishers who need another structure eject this - generator. No template syntax, no new dependency. -- **Markdown that survives a linter**: ATX headings, a blank line around every block, no - hard tabs, and one sentence per line in prose. -- **Escapes what descriptions contain**: a summary or description is arbitrary text, so - pipes are escaped inside table cells and newlines collapse to spaces. - -## Emitters that implement it - -`emitters/sdk-docs.ts` (the page renderer), over the IR and the `sample` hooks the -pipeline supplies. - -## Ejecting it - -`redocly eject-generator sdk-docs` ships this generator BUNDLED with its renderer — one -small `.mjs` you own, importing `@redocly/client-generator` and `@redocly/openapi-core`. -The language generators are not bundled with it, because the samples arrive as data. - -## The modify loop - -1. Edit this skill: state the new behavior or decision. -2. Change `emitters/sdk-docs.ts` (the entry is plumbing — it rarely moves). -3. Verify: `npm run compile`, the emitter unit suites - (`VITEST_SUITE=unit npx vitest run packages/client-generator/src/emitters`), and - `tests/e2e/generate-client/sdk-docs.test.ts`. diff --git a/packages/client-generator/src/generators/sdk-docs/index.ts b/packages/client-generator/src/generators/sdk-docs/index.ts deleted file mode 100644 index 011ac76f2c..0000000000 --- a/packages/client-generator/src/generators/sdk-docs/index.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { join } from 'node:path'; - -import { renderSdkDocs, type SdkDocsLanguage } from '../../emitters/sdk-docs.js'; -import { NotSupportedError } from '../../errors.js'; -import { anchor } from '../anchor.js'; -import type { Generator, GeneratorOptionsSchema } from '../types.js'; - -/** - * The sdk-docs generator: one Markdown page per SDK selected in the same run - * (`.python.md`, `.go.md`, …). Each page renders from the IR the SDK is built - * from, and takes its call snippets from that SDK generator's own `sample` hook, so a page - * never spells out call syntax a second time. - */ -export const sdkDocsOptions: GeneratorOptionsSchema = { - type: 'object', - properties: { - title: { - type: 'string', - description: 'Page heading. Defaults to " SDK reference".', - }, - frontmatter: { - type: 'boolean', - default: false, - description: 'Emit YAML front matter carrying the title, for docs sites that expect it.', - }, - }, - additionalProperties: false, -}; - -/** The SDKs this generator documents, and the one line each needs beyond the IR. */ -const LANGUAGES: SdkDocsLanguage[] = [ - { - name: 'typescript', - label: 'TypeScript', - fence: 'typescript', - requires: 'The client has no dependencies.', - }, - { name: 'python', label: 'Python', fence: 'python', requires: 'The SDK needs `httpx`.' }, - { - name: 'go', - label: 'Go', - fence: 'go', - requires: 'The SDK needs the standard library only.', - }, - { name: 'php', label: 'PHP', fence: 'php', requires: 'The SDK needs the curl extension.' }, -]; - -export const sdkDocsGenerator: Generator = ({ - model, - outputPath, - emit, - options, - selected, - samples, -}) => { - const documented = LANGUAGES.filter((language) => selected?.includes(language.name)); - if (documented.length === 0) { - throw new NotSupportedError( - 'The "sdk-docs" generator documents an SDK, so also select one of: typescript, python, go, php.' - ); - } - const { dir, stem } = anchor(outputPath); - const title = options?.title as string | undefined; - return documented.map((language) => { - const sample = samples?.[language.name]; - return { - path: join(dir, `${stem}.${language.name}.md`), - content: renderSdkDocs(model, { - // Two pages must not share one heading, so a caller's title carries the language. - title: - title === undefined - ? `${model.title} ${language.label} SDK reference` - : documented.length > 1 - ? `${title} (${language.label})` - : title, - frontmatter: options?.frontmatter === true, - language, - sample: (operation) => sample?.(operation, { model, emit }), - pagination: emit.pagination, - }), - }; - }); -}; diff --git a/packages/client-generator/src/generators/types.ts b/packages/client-generator/src/generators/types.ts index cfea9ed367..cb8c74dba4 100644 --- a/packages/client-generator/src/generators/types.ts +++ b/packages/client-generator/src/generators/types.ts @@ -28,8 +28,6 @@ export type GeneratorName = | 'transformers' | 'mock' | 'cli' - | 'cli-docs' - | 'sdk-docs' | 'python' | 'go' | 'php'; @@ -65,15 +63,6 @@ export type GeneratorInput = { emit: EmitOptions; /** Every generator name in the run — lets a generator adapt to co-selection (cli wires zod validation when `zod` is selected). */ selected?: string[]; - /** - * The `sample` hook of every selected generator that declares one, keyed by generator - * name. A docs generator renders each SDK's own call snippet from these instead of - * importing the SDK generators, which would pull all of them into its bundle. - */ - samples?: Record< - string, - (operation: OperationModel, ctx: SampleContext) => CodeSample | undefined - >; /** * This generator's own options from `client.options.`, already validated against * the schema it declares with defaults applied — a generator reads them without re-checking. @@ -114,6 +103,14 @@ export type GeneratorDescriptor = { /** Optional: one idiomatic call snippet per operation for docs (`x-codeSamples`); * collected into an overlay when `codeSamples` is enabled. Return undefined to skip. */ sample?: (operation: OperationModel, ctx: SampleContext) => CodeSample | undefined; + /** + * Optional: the reference documentation for what `run` emits — a Markdown page per + * generated artifact, returned like `run`'s files. Called only when `client.docs` (or + * `--docs`) is on, so documentation is one switch for the whole run instead of a + * generator name per language. A generator documents ITSELF: nothing else knows its + * call syntax, and ejecting the generator takes its page with it. + */ + docs?: Generator; // `string[]` (not `GeneratorName[]`) so a custom generator may require a built-in or another // custom generator by name; built-in descriptors still type-check (their names are strings). requires?: string[]; diff --git a/packages/client-generator/src/generators/typescript/AGENTS.md b/packages/client-generator/src/generators/typescript/AGENTS.md index aca688d1a6..940a09d833 100644 --- a/packages/client-generator/src/generators/typescript/AGENTS.md +++ b/packages/client-generator/src/generators/typescript/AGENTS.md @@ -53,6 +53,14 @@ for the smaller paths first when they fit: `client.setup` bakes publisher defaul generated client, and middleware or `configure()` change behavior at run time rather than generation time. +- **It documents itself.** With `client.docs` (or `--docs`), the `docs` hook writes + `.typescript.md`: the security schemes, then one section per operation with its parameters, + body, response type, and behavior notes. The call snippets come from this generator's own + `sample` hook, so the page can only show the syntax of the SDK beside it, and the layout + comes from `renderReferencePage` in the authoring toolkit — reachable from an ejected copy + through `@redocly/client-generator`. Pagination on the page is decided by + `paginationRuleFor`, the same helper this generator resolves pagination with. + ## The modify loop 1. Edit this skill: state the new behavior or decision. diff --git a/packages/client-generator/src/generators/typescript/index.ts b/packages/client-generator/src/generators/typescript/index.ts index e3f494357d..0b40b84678 100644 --- a/packages/client-generator/src/generators/typescript/index.ts +++ b/packages/client-generator/src/generators/typescript/index.ts @@ -1,5 +1,6 @@ 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'; @@ -29,6 +30,29 @@ export const typescriptGenerator: Generator = ({ model, outputPath, outputMode, return [{ path: outputPath, content: emitClientSingleFile(model, emit) }]; }; +/** + * The client's own reference page, written when `client.docs` is on. Its snippets come from + * `typescriptSample` below, so the page shows the calling convention this run generated — + * `argsStyle` included. + */ +export const typescriptDocs: Generator = ({ model, outputPath, emit }) => [ + { + path: outputPath.replace(/\.[^.\\/]+$/, '.typescript.md'), + content: renderReferencePage(model, { + title: `${model.title} TypeScript client reference`, + frontmatter: emit.docsFrontmatter === true, + language: { + name: 'typescript', + label: 'TypeScript', + fence: 'typescript', + requires: 'The client has no dependencies.', + }, + sample: (op) => typescriptSample(op, { model, emit }), + pagination: emit.pagination, + }), + }, +]; + /** One idiomatic TS call per operation, for `x-codeSamples` and the SDK reference pages. */ export function typescriptSample(op: OperationModel, ctx: SampleContext): CodeSample { const ident = packageIdents(ctx.model).get(op.name) ?? op.name; diff --git a/packages/client-generator/src/pipeline.ts b/packages/client-generator/src/pipeline.ts index b900ba846f..12990d9379 100644 --- a/packages/client-generator/src/pipeline.ts +++ b/packages/client-generator/src/pipeline.ts @@ -6,7 +6,7 @@ // package. The `/generate` entry re-exports `generateClient` from here and // layers the sync TS toolkit on top. -import { stringifyYaml } from '@redocly/openapi-core'; +import { logger, stringifyYaml } from '@redocly/openapi-core'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, resolve, sep } from 'node:path'; @@ -49,26 +49,27 @@ 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)); - // The sample hooks of this run, so a docs generator renders each SDK's own call snippet - // without importing the SDK generators. - const samples: Record> = {}; - for (const name of options.generators) { - const sample = options.registry.get(name)!.sample; - if (sample !== undefined) samples[name] = sample; - } + let documented = false; for (const name of options.generators) { const generator = options.registry.get(name)!; + const input = { + model, + outputPath: options.outputPath, + outputMode: options.outputMode, + emit: options.emit, + selected: options.generators, + options: options.generatorOptions?.get(name) ?? {}, + }; let generated: GeneratedFile[]; try { - generated = generator.run({ - model, - outputPath: options.outputPath, - outputMode: options.outputMode, - emit: options.emit, - selected: options.generators, - samples, - options: options.generatorOptions?.get(name) ?? {}, - }); + // `docs` documents what `run` emits, so both run behind the same name and their + // files land together. A generator without a `docs` hook simply has no page. + if (options.emit.docs === true && generator.docs !== undefined) { + generated = [...generator.run(input), ...generator.docs(input)]; + documented = true; + } else { + generated = generator.run(input); + } } catch (error) { const message = error instanceof Error ? error.message : String(error); throw new Error(`Generator "${name}" failed: ${message}`); @@ -100,6 +101,13 @@ export function runGenerators( files.push({ path: resolved, content: file.content }); } } + // Asking for documentation and getting none is worth saying: `zod` and the framework + // wrappers document nothing, so a selection of only those writes no page. + if (options.emit.docs === true && !documented) { + logger.warn( + `generate-client: docs is on, but no selected generator writes documentation (${options.generators.join(', ')}).\n` + ); + } return files; } @@ -203,6 +211,8 @@ export async function generateClient( binName: options.binName, goPackage: options.goPackage, pagination: options.pagination, + docs: options.docs, + docsFrontmatter: options.docsFrontmatter, }; // Fail fast on an incompatible selection (missing prerequisite, unsupported // error-mode/date-type/runtime) before producing any file, and warn about options a diff --git a/packages/client-generator/src/types.ts b/packages/client-generator/src/types.ts index 75da79716e..718dc40955 100644 --- a/packages/client-generator/src/types.ts +++ b/packages/client-generator/src/types.ts @@ -110,6 +110,16 @@ export type GenerateClientOptions = { * Config-only (`client.codeSamples`), like `pagination`. */ codeSamples?: boolean; + /** + * Also write reference documentation for what this run generates: one Markdown page per + * selected generator that implements the `docs` hook (`.cli.md` for the CLI, + * `.python.md` for the Python SDK, and so on). One switch for every language, so a + * newly documented generator needs no new flag. The `--docs` flag sets it too. + */ + docs?: boolean; + /** Emit YAML front matter carrying the title above each documentation page, for docs + * sites that expect it. Config-only. */ + docsFrontmatter?: boolean; /** * Auto-pagination rules: a convention rule (applied to every operation it * structurally fits), per-operation overrides, and `exclude`d operationIds — 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 02c107e9a5..6d7b0b7723 100644 --- a/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap +++ b/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap @@ -234,6 +234,12 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "Date", ], }, + "docs": { + "type": "boolean", + }, + "docsFrontmatter": { + "type": "boolean", + }, "errorMode": { "enum": [ "throw", diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts index 823d2c5005..cc2e3b5ba8 100644 --- a/packages/core/src/types/redocly-yaml.ts +++ b/packages/core/src/types/redocly-yaml.ts @@ -388,6 +388,8 @@ const Client: NodeType = { mockSeed: { type: 'number' }, queryKeyPrefix: { type: 'string' }, codeSamples: { type: 'boolean' }, + docs: { type: 'boolean' }, + docsFrontmatter: { type: 'boolean' }, setup: { type: 'string' }, options: mapOf('ClientGeneratorOptions'), pagination: 'ClientPagination', diff --git a/tests/e2e/generate-client/cli-docs.test.ts b/tests/e2e/generate-client/cli-docs.test.ts deleted file mode 100644 index 64c4e2f1e6..0000000000 --- a/tests/e2e/generate-client/cli-docs.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -// The cli-docs generator end-to-end: the page it writes must describe the CLI that ships -// beside it, so the bar is a comparison against the generated CLI's own `--help`. -import { spawnSync } from 'node:child_process'; -import { existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { generate, repoRoot, tsxBin } from './helpers.js'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const fixture = join(__dirname, 'fixtures/cli.yaml'); - -let dir: string; -let page: string; - -// Generating and spawning the CLI through tsx can approach the 5s default under load. -vi.setConfig({ testTimeout: 120_000 }); - -beforeAll(() => { - dir = mkdtempSync(join(tmpdir(), 'cli-docs-')); - writeFileSync(join(dir, 'package.json'), JSON.stringify({ type: 'module' }), 'utf-8'); - // The CLI validates with zod, and this temp dir is outside the repo: borrow its modules. - symlinkSync(join(repoRoot, 'node_modules'), join(dir, 'node_modules'), 'dir'); - // `cli-docs` pulls in the CLI it documents, so this one flag is the whole selection. - generate(fixture, join(dir, 'cafe.client.ts'), ['--generator', 'cli-docs']); - page = readFileSync(join(dir, 'cafe.client.cli.md'), 'utf-8'); -}); - -afterAll(() => { - rmSync(dir, { recursive: true, force: true }); -}); - -describe('generate-client cli-docs generator (end-to-end)', () => { - it('emits the page beside the CLI it documents, pulling the CLI in on its own', () => { - expect(existsSync(join(dir, 'cafe.client.cli.ts'))).toBe(true); - expect(existsSync(join(dir, 'cafe.client.cli.md'))).toBe(true); - }); - - it('documents every command the CLI dispatches, addressed exactly as --help shows it', () => { - const help = (args: string[]): string => { - const result = spawnSync(tsxBin, [join(dir, 'cafe.client.cli.ts'), ...args], { - cwd: dir, - encoding: 'utf-8', - }); - expect(result.status, result.stderr).toBe(0); - return result.stdout; - }; - /** The `Commands:` block of a help screen, one entry per line, summaries stripped. */ - const entries = (text: string): string[] => - text - .slice(text.indexOf('Commands:') + 'Commands:'.length, text.indexOf('Global flags:')) - .split('\n') - .map((line) => line.trim()) - .filter((line) => line !== '') - .map((line) => line.split(/\s{2,}/)[0]); - - // Top-level help lists groups (`orders `) and any ungrouped command; each - // group's own help lists its commands. Walk both levels, so nothing is assumed. - const addresses: string[] = []; - for (const entry of entries(help(['--help']))) { - if (entry.endsWith(' ')) { - addresses.push(...entries(help([entry.replace(' ', ''), '--help']))); - } else { - addresses.push(entry); - } - } - - expect(addresses.length).toBeGreaterThan(3); - for (const address of addresses) { - expect(page, `${address} is missing from the reference page`).toContain(`### \`${address}\``); - } - }); - - it('carries the credential variables and exit codes the CLI actually uses', () => { - // The env prefix is derived from the same stem the CLI derives it from. - expect(page).toContain('CAFE_CLIENT_TOKEN'); - expect(page).toContain('| 3 | validation error |'); - }); - - it('is well-formed Markdown: one H1, balanced fences, no tabs or trailing spaces', () => { - const lines = page.split('\n'); - expect(lines.filter((line) => line.startsWith('# '))).toHaveLength(1); - expect(lines.filter((line) => line.startsWith('```')).length % 2).toBe(0); - expect(page).not.toContain('\t'); - expect(lines.filter((line) => /\s$/.test(line))).toEqual([]); - // A heading and a table never sit on adjacent lines — markdownlint (MD022/MD058) and - // most renderers need the blank line. - for (let index = 1; index < lines.length; index++) { - if (lines[index].startsWith('|') && lines[index - 1] !== '') { - expect(lines[index - 1].startsWith('|')).toBe(true); - } - } - }); -}); diff --git a/tests/e2e/generate-client/docs.test.ts b/tests/e2e/generate-client/docs.test.ts new file mode 100644 index 0000000000..e9c69ed6e2 --- /dev/null +++ b/tests/e2e/generate-client/docs.test.ts @@ -0,0 +1,122 @@ +// Reference documentation end-to-end: `--docs` is one switch for the whole run, and each +// generator documents ITSELF — so the bar is that every selected generator with a page +// produced one, that no page appears without the switch, and that each page describes the +// artifact beside it (the CLI page against the CLI's own `--help`, an SDK page against the +// call syntax that SDK generates). +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { generate, repoRoot, tsxBin } from './helpers.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixture = join(__dirname, 'fixtures/cli.yaml'); + +let dir: string; +let cliPage: string; +let pythonPage: string; + +vi.setConfig({ testTimeout: 120_000 }); + +beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'client-docs-')); + writeFileSync(join(dir, 'package.json'), JSON.stringify({ type: 'module' }), 'utf-8'); + // The generated CLI validates with zod, and this temp dir is outside the repo. + symlinkSync(join(repoRoot, 'node_modules'), join(dir, 'node_modules'), 'dir'); + generate(fixture, join(dir, 'cafe.client.ts'), [ + '--generator', + 'cli', + '--generator', + 'python', + '--docs', + ]); + cliPage = readFileSync(join(dir, 'cafe.client.cli.md'), 'utf-8'); + pythonPage = readFileSync(join(dir, 'cafe.client.python.md'), 'utf-8'); +}); + +afterAll(() => { + rmSync(dir, { recursive: true, force: true }); +}); + +describe('generate-client --docs (end-to-end)', () => { + it('writes one page per selected generator that documents itself, and none for the rest', () => { + // cli and python asked for; typescript and zod came in as prerequisites of cli, and + // typescript documents itself too. zod has no page. + expect(existsSync(join(dir, 'cafe.client.cli.md'))).toBe(true); + expect(existsSync(join(dir, 'cafe.client.python.md'))).toBe(true); + expect(existsSync(join(dir, 'cafe.client.typescript.md'))).toBe(true); + expect(existsSync(join(dir, 'cafe.client.zod.md'))).toBe(false); + }); + + it('writes no page without the switch', () => { + const plain = mkdtempSync(join(tmpdir(), 'client-nodocs-')); + try { + generate(fixture, join(plain, 'c.ts'), ['--generator', 'python']); + expect(existsSync(join(plain, 'c.py'))).toBe(true); + expect(existsSync(join(plain, 'c.python.md'))).toBe(false); + } finally { + rmSync(plain, { recursive: true, force: true }); + } + }); + + it('documents every command the CLI dispatches, addressed exactly as --help shows it', () => { + const help = (args: string[]): string => { + const result = spawnSync(tsxBin, [join(dir, 'cafe.client.cli.ts'), ...args], { + cwd: dir, + encoding: 'utf-8', + }); + expect(result.status, result.stderr).toBe(0); + return result.stdout; + }; + const entries = (text: string): string[] => + text + .slice(text.indexOf('Commands:') + 'Commands:'.length, text.indexOf('Global flags:')) + .split('\n') + .map((line) => line.trim()) + .filter((line) => line !== '') + .map((line) => line.split(/\s{2,}/)[0]); + + const addresses: string[] = []; + for (const entry of entries(help(['--help']))) { + if (entry.endsWith(' ')) { + addresses.push(...entries(help([entry.replace(' ', ''), '--help']))); + } else { + addresses.push(entry); + } + } + expect(addresses.length).toBeGreaterThan(3); + for (const address of addresses) { + expect(cliPage, `${address} is missing from the reference page`).toContain( + `### \`${address}\`` + ); + } + expect(cliPage).toContain('CAFE_CLIENT_TOKEN'); + expect(cliPage).toContain('| 3 | validation error |'); + }); + + it('shows each SDK page its own call syntax, taken from that generator', () => { + expect(pythonPage).toContain('```python'); + expect(pythonPage).toContain('client.list_orders('); + expect(pythonPage).toContain('| `status` | query |'); + expect(pythonPage).toContain('BearerAuth'); + // listOrders declares x-redoclyPagination, resolved by the helper the SDK uses. + expect(pythonPage).toContain('This operation is paginated'); + }); + + it('is well-formed Markdown: one H1, balanced fences, no tabs or trailing spaces', () => { + for (const page of [cliPage, pythonPage]) { + const lines = page.split('\n'); + expect(lines.filter((line) => line.startsWith('# '))).toHaveLength(1); + expect(lines.filter((line) => line.startsWith('```')).length % 2).toBe(0); + expect(page).not.toContain('\t'); + expect(lines.filter((line) => /\s$/.test(line))).toEqual([]); + for (let index = 1; index < lines.length; index++) { + if (lines[index].startsWith('|') && lines[index - 1] !== '') { + expect(lines[index - 1].startsWith('|')).toBe(true); + } + } + } + }); +}); diff --git a/tests/e2e/generate-client/examples/README.md b/tests/e2e/generate-client/examples/README.md index 07968661e8..4eef5b171a 100644 --- a/tests/e2e/generate-client/examples/README.md +++ b/tests/e2e/generate-client/examples/README.md @@ -7,31 +7,31 @@ The rest carry their own. The generated client under `src/api/` is gitignored — CI regenerates every client and type-checks the consumer code against it (the `examples` job), and `zero-install-quickstart` keeps its client committed as the canonical browsable copy, drift-checked in `tests/e2e/generate-client/examples.test.ts`. -| Example | How it's generated | Shows | -| ---------------------------------------------------------- | ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | -| [fetch-functions](./fetch-functions) | CLI · `typescript`, functions | free functions + `ApiError` | -| [baked-setup](./baked-setup) | CLI · `typescript`, functions | publisher defaults baked into the client via `--setup` (`defineClientSetup`) | -| [zod](./zod) | CLI · `typescript`, `zod` | validating responses with generated zod schemas | -| [tanstack-query](./tanstack-query) | CLI · `typescript`, `tanstack-query` | React `useQuery(Options())` | -| [mock](./mock) | CLI · `typescript`, `mock` | MSW handlers from generated `handlers` | -| [programmatic](./programmatic) | `generateClient(...)` API | generating the client from a Node script | -| [package-runtime](./package-runtime) | CLI · `typescript`, package runtime | `runtime: package` — types + descriptors only; the versioned runtime is imported from `@redocly/client-generator`, fixes via `npm update` | -| [zero-install-quickstart](./zero-install-quickstart) | CLI · `typescript` | the first-touch loop: generate → import → call; one self-contained file, zero runtime dependencies | -| [node-native](./node-native) | CLI · `typescript` | `importExt: ts` — `.ts` import specifiers so plain `node src/main.ts` runs the client via Node's built-in type stripping | -| [configure-and-middleware](./configure-and-middleware) | CLI · `typescript` | `configure({ serverUrl, retry, fetch })`, `use()` targeting `ctx.operation` (literal unions), body mutation, auth setter, `ApiError.body` | -| [multi-instance](./multi-instance) | CLI · `typescript`, package runtime | per-tenant instances via `createClient(OPERATIONS)` — works in both runtimes; this example uses `runtime: package` | -| [sse-streaming](./sse-streaming) | CLI · `typescript` | typed `for await` over SSE, auto-reconnect via `Last-Event-ID` (`reconnectDelay`/`reconnect: false`), clean abort | -| [vendored-edge](./vendored-edge) | CLI · `typescript` | the generated file copied into a no-npm edge worker (`export default { fetch }`); `typescript` is the only dev tool | -| [pagination](./pagination) | CLI · `typescript` | auto-pagination from a `client.pagination` convention: `for await` over `.items()`/`.pages()` next to the unchanged one-shot call | -| [custom-pagination](./custom-pagination) | CLI · `typescript` | hand-written paging over the typed client for shapes the built-in styles don't cover (body cursors) | -| [custom-generator](./custom-generator) | CLI · `typescript` + custom generator | a local `generators` plugin emitting a `: 'METHOD /path'` route map next to the client | -| [typescript-types-generator](./typescript-types-generator) | CLI · `typescript` + custom generator | a plugin rendering real TypeScript types via `@redocly/client-generator/generate` (`tsType`) — a typed response-shape map | -| [nested-facade](./nested-facade) | CLI · `typescript` + custom generator | `api..` facade derived from the spec's tags by a plugin — regenerates with the spec | -| [cli](./cli) | CLI · `typescript`, `zod`, `cli` | a bin-ready command-line interface over the client: typed flags, `--json` bodies, `--dry-run`, a documented exit-code contract | -| [python-sdk](./python-sdk) | CLI · `python` | a full Python SDK (httpx): typed dataclasses, sync/async clients, pagination iterators | -| [go-sdk](./go-sdk) | CLI · `go` | a full Go SDK (stdlib-only): typed structs, `(T, error)` methods, `context.Context` | -| [php-sdk](./php-sdk) | CLI · `php` | a full PHP SDK (curl extension): promoted-constructor classes, native enums, named-argument methods | -| [ejected-generator](./ejected-generator) | CLI · ejected `php` | `eject-generator php` vendored + customized: the path entry shadows the built-in name; regeneration keeps the customization | +| Example | How it's generated | Shows | +| ---------------------------------------------------------- | ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | +| [fetch-functions](./fetch-functions) | CLI · `typescript`, functions | free functions + `ApiError` | +| [baked-setup](./baked-setup) | CLI · `typescript`, functions | publisher defaults baked into the client via `--setup` (`defineClientSetup`) | +| [zod](./zod) | CLI · `typescript`, `zod` | validating responses with generated zod schemas | +| [tanstack-query](./tanstack-query) | CLI · `typescript`, `tanstack-query` | React `useQuery(Options())` | +| [mock](./mock) | CLI · `typescript`, `mock` | MSW handlers from generated `handlers` | +| [programmatic](./programmatic) | `generateClient(...)` API | generating the client from a Node script | +| [package-runtime](./package-runtime) | CLI · `typescript`, package runtime | `runtime: package` — types + descriptors only; the versioned runtime is imported from `@redocly/client-generator`, fixes via `npm update` | +| [zero-install-quickstart](./zero-install-quickstart) | CLI · `typescript` | the first-touch loop: generate → import → call; one self-contained file, zero runtime dependencies | +| [node-native](./node-native) | CLI · `typescript` | `importExt: ts` — `.ts` import specifiers so plain `node src/main.ts` runs the client via Node's built-in type stripping | +| [configure-and-middleware](./configure-and-middleware) | CLI · `typescript` | `configure({ serverUrl, retry, fetch })`, `use()` targeting `ctx.operation` (literal unions), body mutation, auth setter, `ApiError.body` | +| [multi-instance](./multi-instance) | CLI · `typescript`, package runtime | per-tenant instances via `createClient(OPERATIONS)` — works in both runtimes; this example uses `runtime: package` | +| [sse-streaming](./sse-streaming) | CLI · `typescript` | typed `for await` over SSE, auto-reconnect via `Last-Event-ID` (`reconnectDelay`/`reconnect: false`), clean abort | +| [vendored-edge](./vendored-edge) | CLI · `typescript` | the generated file copied into a no-npm edge worker (`export default { fetch }`); `typescript` is the only dev tool | +| [pagination](./pagination) | CLI · `typescript` | auto-pagination from a `client.pagination` convention: `for await` over `.items()`/`.pages()` next to the unchanged one-shot call | +| [custom-pagination](./custom-pagination) | CLI · `typescript` | hand-written paging over the typed client for shapes the built-in styles don't cover (body cursors) | +| [custom-generator](./custom-generator) | CLI · `typescript` + custom generator | a local `generators` plugin emitting a `: 'METHOD /path'` route map next to the client | +| [typescript-types-generator](./typescript-types-generator) | CLI · `typescript` + custom generator | a plugin rendering real TypeScript types via `@redocly/client-generator/generate` (`tsType`) — a typed response-shape map | +| [nested-facade](./nested-facade) | CLI · `typescript` + custom generator | `api..` facade derived from the spec's tags by a plugin — regenerates with the spec | +| [cli](./cli) | CLI · `typescript`, `zod`, `cli` · `docs` | a bin-ready command-line interface over the client: typed flags, `--json` bodies, `--dry-run`, a documented exit-code contract | +| [python-sdk](./python-sdk) | CLI · `python` · `docs` | a full Python SDK (httpx): typed dataclasses, sync/async clients, pagination iterators | +| [go-sdk](./go-sdk) | CLI · `go` | a full Go SDK (stdlib-only): typed structs, `(T, error)` methods, `context.Context` | +| [php-sdk](./php-sdk) | CLI · `php` | a full PHP SDK (curl extension): promoted-constructor classes, native enums, named-argument methods | +| [ejected-generator](./ejected-generator) | CLI · ejected `php` | `eject-generator php` vendored + customized: the path entry shadows the built-in name; regeneration keeps the customization | ## Run one diff --git a/tests/e2e/generate-client/examples/cli/README.md b/tests/e2e/generate-client/examples/cli/README.md index 976c21e3fb..f3303d6074 100644 --- a/tests/e2e/generate-client/examples/cli/README.md +++ b/tests/e2e/generate-client/examples/cli/README.md @@ -23,6 +23,6 @@ Credentials come from environment variables derived from the file stem: `CLIENT_ Exit codes are a documented contract (0 ok, 1 API error, 2 auth, 3 validation, 4 usage), and errors print one JSON object to stderr so stdout stays clean for piping. To ship a real bin, compile with `tsc` and point `package.json`'s `bin` at the compiled file. -The `cli-docs` generator (also selected here) writes `src/api/client.cli.md` alongside it: the Markdown reference for this CLI — usage, global flags, credential variables, exit codes, and every command with its arguments and flags. +`client.docs: true` (the `--docs` flag) is set here, so the CLI also writes its own reference next to itself: `src/api/client.cli.md` — usage, global flags, credential variables, exit codes, and every command with its arguments and flags. It renders from the same command table the CLI dispatches on, so the page cannot drift from the tool; regenerate and the docs follow. -`client.options.cli-docs` sets the page title here, and `redocly eject-generator cli-docs` hands over the renderer itself if you want a different structure — the renderer is the template. +The page belongs to the `cli` generator, so `redocly eject-generator cli` hands over the layout with the generator — the renderer is the template. diff --git a/tests/e2e/generate-client/examples/cli/redocly.yaml b/tests/e2e/generate-client/examples/cli/redocly.yaml index f45a484037..07a7bd0201 100644 --- a/tests/e2e/generate-client/examples/cli/redocly.yaml +++ b/tests/e2e/generate-client/examples/cli/redocly.yaml @@ -8,7 +8,4 @@ apis: - typescript - zod - cli - - cli-docs - options: - cli-docs: - title: Cafe CLI + docs: true 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 deab5cb79c..ef717aabf5 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 @@ -28,6 +28,12 @@ export default { sample(operation, { model, emit }) { return { lang: 'python', source: '…' }; }, + // Optional: the reference page for what `run` emits, written when `client.docs` (or + // --docs) is on. Same `{ path, content }` shape as `run`; `renderReferencePage` gives + // the standard layout and takes `sample` for its snippets. A generator documents itself. + docs({ model, outputPath, emit }) { + return [{ path: outputPath.replace(/\.ts$/, '.mine.md'), content: '…' }]; + }, }; ``` @@ -102,6 +108,7 @@ discriminated union on `kind`: `scalar`, `array`, `object`, `record`, `ref`, | `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). | 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 955051041b..8863cb1fd7 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 @@ -87,6 +87,14 @@ $idempotencyKey` on mutating methods. - Session/bearer token flows map to `auth: ['bearer' => $tokenProvider]` with a callable — resolved per request, so refresh needs no client rebuild. +- **It documents itself.** With `client.docs` (or `--docs`), the `docs` hook writes + `.php.md`: the security schemes, then one section per operation with its parameters, + body, response type, and behavior notes. The call snippets come from this generator's own + `sample` hook, so the page can only show the syntax of the SDK beside it, and the layout + comes from `renderReferencePage` in the authoring toolkit — reachable from an ejected copy + through `@redocly/client-generator`. Pagination on the page is decided by + `paginationRuleFor`, the same helper this generator resolves pagination with. + ## The modify loop 1. Edit this skill: state the new behavior or decision. diff --git a/tests/e2e/generate-client/examples/python-sdk/README.md b/tests/e2e/generate-client/examples/python-sdk/README.md index 8db5288dce..439f8a2c4c 100644 --- a/tests/e2e/generate-client/examples/python-sdk/README.md +++ b/tests/e2e/generate-client/examples/python-sdk/README.md @@ -1,6 +1,7 @@ # python-sdk The `python` generator emits `src/api/client.py`. +`client.docs: true` (the `--docs` flag) is set here too, so the generator also writes its own reference: `src/api/client.python.md` — every operation with its parameters, body, response type, and a Python call sample. 'It is a full Python SDK over [httpx](https://www.python-httpx.org/) (Python ≥ 3.9): - typed dataclass models diff --git a/tests/e2e/generate-client/examples/python-sdk/redocly.yaml b/tests/e2e/generate-client/examples/python-sdk/redocly.yaml index 13e012a530..df5b32da7c 100644 --- a/tests/e2e/generate-client/examples/python-sdk/redocly.yaml +++ b/tests/e2e/generate-client/examples/python-sdk/redocly.yaml @@ -6,3 +6,5 @@ apis: client: generators: - python + # One switch documents whatever the run generates: `src/api/client.python.md`. + docs: true diff --git a/tests/e2e/generate-client/sdk-docs.test.ts b/tests/e2e/generate-client/sdk-docs.test.ts deleted file mode 100644 index 0fa022e44d..0000000000 --- a/tests/e2e/generate-client/sdk-docs.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -// The sdk-docs generator end-to-end: one page per selected SDK, and each page must show -// the call syntax of the SDK beside it — so the bar is the snippet each language -// generator produces, not a snippet this test invents. -import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { generate } from './helpers.js'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const fixture = join(__dirname, 'fixtures/cli.yaml'); - -let dir: string; -let python: string; -let go: string; - -vi.setConfig({ testTimeout: 120_000 }); - -beforeAll(() => { - dir = mkdtempSync(join(tmpdir(), 'sdk-docs-')); - generate(fixture, join(dir, 'cafe.client.ts'), [ - '--generator', - 'python', - '--generator', - 'go', - '--generator', - 'sdk-docs', - ]); - python = readFileSync(join(dir, 'cafe.client.python.md'), 'utf-8'); - go = readFileSync(join(dir, 'cafe.client.go.md'), 'utf-8'); -}); - -afterAll(() => { - rmSync(dir, { recursive: true, force: true }); -}); - -describe('generate-client sdk-docs generator (end-to-end)', () => { - it('writes one page per selected SDK, and none for an SDK that is not selected', () => { - expect(existsSync(join(dir, 'cafe.client.python.md'))).toBe(true); - expect(existsSync(join(dir, 'cafe.client.go.md'))).toBe(true); - expect(existsSync(join(dir, 'cafe.client.typescript.md'))).toBe(false); - expect(existsSync(join(dir, 'cafe.client.php.md'))).toBe(false); - }); - - it('documents every operation, grouped by tag, with its method and path', () => { - for (const page of [python, go]) { - expect(page).toContain('## orders'); - for (const operation of ['listOrders', 'createOrder', 'getOrder', 'ping']) { - expect(page).toContain(`### \`${operation}\``); - } - expect(page).toContain('`GET /orders/{orderId}`'); - } - }); - - it('shows each language its own call syntax, taken from that generator', () => { - expect(python).toContain('```python'); - expect(python).toContain('client.list_orders('); - expect(go).toContain('```go'); - expect(go).toContain('client.ListOrders('); - // Each page carries one language: the Python page never shows the Go call. - expect(python).not.toContain('client.ListOrders('); - expect(go).not.toContain('client.list_orders('); - }); - - it('notes the behavior an SDK call has beyond a plain JSON request', () => { - // listOrders declares x-redoclyPagination; the note must come from the resolver the - // language SDKs use, so a page never disagrees with the SDK next to it. - expect(python).toContain('This operation is paginated'); - expect(go).toContain('This operation is paginated'); - - const streaming = mkdtempSync(join(tmpdir(), 'sdk-docs-sse-')); - try { - generate(join(__dirname, 'fixtures/sse.yaml'), join(streaming, 'client.ts'), [ - '--generator', - 'python', - '--generator', - 'sdk-docs', - ]); - expect(readFileSync(join(streaming, 'client.python.md'), 'utf-8')).toContain( - 'streams server-sent events' - ); - } finally { - rmSync(streaming, { recursive: true, force: true }); - } - }); - - it('carries the parameters, the body, and the security schemes from the description', () => { - expect(python).toContain('| `status` | query |'); - expect(python).toContain('| `orderId` | path |'); - expect(python).toContain('application/json'); - expect(python).toContain('BearerAuth'); - }); - - it('fails with the fix in the message when no SDK is selected', () => { - expect(() => generate(fixture, join(dir, 'alone.ts'), ['--generator', 'sdk-docs'])).toThrow( - /also select/ - ); - }); - - it('is well-formed Markdown: one H1, balanced fences, no tabs or trailing spaces', () => { - const lines = python.split('\n'); - expect(lines.filter((line) => line.startsWith('# '))).toHaveLength(1); - expect(lines.filter((line) => line.startsWith('```')).length % 2).toBe(0); - expect(python).not.toContain('\t'); - expect(lines.filter((line) => /\s$/.test(line))).toEqual([]); - for (let index = 1; index < lines.length; index++) { - if (lines[index].startsWith('|') && lines[index - 1] !== '') { - expect(lines[index - 1].startsWith('|')).toBe(true); - } - } - }); -}); From fd65869a657f4e3096ce8ebbbf9761535e642fba Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 18 Aug 2026 13:10:00 +0300 Subject: [PATCH 189/211] docs: document --docs and the generator docs hook The guide had one section per docs generator; it now has one "Reference documentation" section for the switch, with a table of which generator writes which page. The command reference gains `--docs`, the client reference gains `docs` and `docsFrontmatter`, the eject page says a generator carries its page, and the custom-generator guide shows the `docs` hook with `renderReferencePage`. --- .changeset/agent-friendly-generators.md | 4 +- docs/@v2/commands/eject-generator.md | 3 +- docs/@v2/commands/generate-client.md | 1 + docs/@v2/configuration/reference/client.md | 42 ++++++------ .../@v2/guides/customize-client-generation.md | 47 ++++++++++++-- docs/@v2/guides/use-generated-client.md | 65 +++++-------------- packages/client-generator/ARCHITECTURE.md | 2 +- 7 files changed, 89 insertions(+), 75 deletions(-) diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index 76857f8237..302eac91e9 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -3,7 +3,9 @@ '@redocly/cli': minor --- -Added agent-friendly client generation: `python`, `go`, `php`, `cli`, `cli-docs`, and `sdk-docs` generators in addition to TypeScript generators. +Added agent-friendly client generation: `python`, `go`, `php`, and `cli` generators in addition to TypeScript generators. + +Added `--docs` (`client.docs`), which writes the reference documentation for what a run generates: each generator documents itself with one Markdown page next to its output. Added composable generated CLIs (custom commands, one binary over several APIs via `client.cliOutput`). diff --git a/docs/@v2/commands/eject-generator.md b/docs/@v2/commands/eject-generator.md index be4e9284f4..454fdaa316 100644 --- a/docs/@v2/commands/eject-generator.md +++ b/docs/@v2/commands/eject-generator.md @@ -9,7 +9,8 @@ Do not edit it manually. You or your agent edit the generator, and the `redocly generate-client` command rebuilds the client. When the spec changes later, the command regenerates the client and keeps your customization. -You can eject every built-in generator: the SDKs (`typescript`, `python`, `go`, `php`) and the add-on generators (`zod`, `mock`, `cli`, `cli-docs`, `sdk-docs`, `swr`, `tanstack-query`, `transformers`). +You can eject every built-in generator: the SDKs (`typescript`, `python`, `go`, `php`) and the add-on generators (`zod`, `mock`, `cli`, `swr`, `tanstack-query`, `transformers`). +A generator that writes reference documentation carries that page with it, so ejecting `cli` or `python` also hands you the layout of its page. The `tanstack-query-vue`, `-svelte`, and `-solid` variants are the same generator with one different argument. Eject `tanstack-query` and set the framework in your copy. diff --git a/docs/@v2/commands/generate-client.md b/docs/@v2/commands/generate-client.md index 4926fc00a7..d122771646 100644 --- a/docs/@v2/commands/generate-client.md +++ b/docs/@v2/commands/generate-client.md @@ -57,6 +57,7 @@ redocly generate-client [--help] [--version] | `--server-url` | string | Overrides the default server URL in the client. The option accepts an absolute URL (`https://api.example.com`) or a relative URL (`/v1`). Defaults to `servers[0].url`. The app can also change the server URL at runtime with `createClient({ serverUrl })` or `configure({ serverUrl })`. See [Authentication](../guides/use-generated-client.md#authentication) in the usage guide. | | `--setup` | string | The path to a publisher setup module that the command includes in the client. Use it to pre-configure defaults, for example the server URL, retries, headers, and middleware. A published SDK then contains these defaults. See [Publisher defaults](../guides/customize-client-generation.md#publisher-defaults). | | `--bin-name` | string | The command name that the `cli` generator prints in the help output. The generator also uses it to derive the names of the credential environment variables. Defaults to the output file name (without extension) with non-word characters converted to `-`. | +| `--docs` | boolean | Also write the reference documentation for what this run generates: one Markdown page for each selected generator that documents itself (the CLI, and each SDK). Default value is `false`. | | `--go-package` | string | The package clause in the output of the `go` generator. It must be a valid Go package name (lowercase letters, digits, and `_`; it must not start with a digit or be a keyword). Default value is `client`. | | `--config` | string | Specify the path to the [configuration file](#generate-from-the-configuration-file). | | `--help` | boolean | Display help. | diff --git a/docs/@v2/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md index 09ce730971..6b38496387 100644 --- a/docs/@v2/configuration/reference/client.md +++ b/docs/@v2/configuration/reference/client.md @@ -22,26 +22,28 @@ It is a structured, durable contract that belongs in versioned configuration, no If you run without a configuration file, declare pagination for each operation with the `x-redoclyPagination` extension in the description. As an alternative, pass `pagination` to the programmatic `generateClient(...)`. -| Option | Type | Description | -| ---------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `generators` | [string] | The generators to run, in order. Each entry is a built-in name (`typescript`, `zod`, `tanstack-query` or its `-vue`/`-svelte`/`-solid` variants, `swr`, `mock`, `transformers`, `cli`, `cli-docs`, `sdk-docs`, `python`, `go`, `php`), or the path or package name of a custom generator. | -| `outputMode` | string | The file layout: `single` or `split`. This option applies to TypeScript output only. The `python`, `go`, and `php` SDKs always emit one self-contained file. | -| `runtime` | string | The runtime distribution: `inline` or `package`. This option applies to TypeScript output only. The `python`, `go`, and `php` SDKs always embed their runtime. | -| `importExt` | string | The extension in generated relative imports: `js` (default, for tsc and bundlers) or `ts` (for Node's built-in type stripping). This option applies to TypeScript output only. | -| `argsStyle` | string | How the client receives operation inputs: `flat` or `grouped`. This option applies to TypeScript output only. Each language SDK follows its own idiom (keyword arguments, named arguments, a params struct). | -| `errorMode` | string | How operations report HTTP errors: `throw` or `result`. The `python` SDK implements both. The `go` and `php` SDKs support only `throw`, because that is the language idiom, and they reject `result`. | -| `dateType` | string | The type of `date`/`date-time` fields: `string` or `Date`. Every language applies it: `Date` in TypeScript, `datetime`/`date` in Python, `time.Time`/`Date` in Go, `DateTimeImmutable` in PHP. | -| `mockData` | string | The data mode for the `mock` generator: `static` or `faker`. | -| `mockSeed` | number | The seed for mocks in `faker` mode. | -| `queryKeyPrefix` | string | The first element of every `tanstack-query` query key and mutation key. It separates the cache entries when several generated APIs share one QueryClient. This option is available only in the configuration file and has no flag. | -| `codeSamples` | boolean | Emit `.code-samples.yaml` next to the client. This file is an OpenAPI Overlay that adds `x-codeSamples` to each operation. The samples come from each selected generator that implements `sample()`. This option is available only in the configuration file and has no flag. | -| `serverUrl` | string | The server URL that the client includes as its default. If you do not set it, the client uses `servers[0].url`. | -| `goPackage` | string | The package clause for the output of the `go` generator. The value must be a valid Go package name: lowercase letters, digits, and `_`, with no digit at the start, and not a keyword. An invalid value stops generation, so the generator does not emit a file that Go cannot compile. Default: `client`. | -| `binName` | string | The command name that the `cli` generator shows in the help output and uses to derive its credential environment variables. The default is the output file name (without extension), sanitized. | -| `cliOutput` | string | The path of a composed CLI entry. The entry includes every api that emits a cli module: from the `cli` generator by name, ejected, or included as a prerequisite. The result is one binary. You address each api by its alias, and each api has `__*` credential variables. This option is available only in the top-level `client` block. See [Compose and extend the CLI](../../guides/use-generated-client.md#compose-and-extend-the-cli). | -| `options` | object | Options for each generator, keyed by generator name. The command validates each entry against the schema that the generator declares. See [Custom generators](../../guides/customize-client-generation.md#custom-generators). | -| `setup` | string | The path to a publisher setup module that the client includes. The module sets defaults such as the server URL, retries, headers, and middleware. See [Publisher defaults](../../guides/customize-client-generation.md#publisher-defaults). | -| `pagination` | [Pagination object](#pagination-object) | Declares how the API paginates. Paginated operations then get typed `.pages()`/`.items()` async iterators. | +| Option | Type | Description | +| ----------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `generators` | [string] | The generators to run, in order. Each entry is a built-in name (`typescript`, `zod`, `tanstack-query` or its `-vue`/`-svelte`/`-solid` variants, `swr`, `mock`, `transformers`, `cli`, `python`, `go`, `php`), or the path or package name of a custom generator. | +| `outputMode` | string | The file layout: `single` or `split`. This option applies to TypeScript output only. The `python`, `go`, and `php` SDKs always emit one self-contained file. | +| `runtime` | string | The runtime distribution: `inline` or `package`. This option applies to TypeScript output only. The `python`, `go`, and `php` SDKs always embed their runtime. | +| `importExt` | string | The extension in generated relative imports: `js` (default, for tsc and bundlers) or `ts` (for Node's built-in type stripping). This option applies to TypeScript output only. | +| `argsStyle` | string | How the client receives operation inputs: `flat` or `grouped`. This option applies to TypeScript output only. Each language SDK follows its own idiom (keyword arguments, named arguments, a params struct). | +| `errorMode` | string | How operations report HTTP errors: `throw` or `result`. The `python` SDK implements both. The `go` and `php` SDKs support only `throw`, because that is the language idiom, and they reject `result`. | +| `dateType` | string | The type of `date`/`date-time` fields: `string` or `Date`. Every language applies it: `Date` in TypeScript, `datetime`/`date` in Python, `time.Time`/`Date` in Go, `DateTimeImmutable` in PHP. | +| `mockData` | string | The data mode for the `mock` generator: `static` or `faker`. | +| `mockSeed` | number | The seed for mocks in `faker` mode. | +| `queryKeyPrefix` | string | The first element of every `tanstack-query` query key and mutation key. It separates the cache entries when several generated APIs share one QueryClient. This option is available only in the configuration file and has no flag. | +| `codeSamples` | boolean | Emit `.code-samples.yaml` next to the client. This file is an OpenAPI Overlay that adds `x-codeSamples` to each operation. The samples come from each selected generator that implements `sample()`. This option is available only in the configuration file and has no flag. | +| `serverUrl` | string | The server URL that the client includes as its default. If you do not set it, the client uses `servers[0].url`. | +| `goPackage` | string | The package clause for the output of the `go` generator. The value must be a valid Go package name: lowercase letters, digits, and `_`, with no digit at the start, and not a keyword. An invalid value stops generation, so the generator does not emit a file that Go cannot compile. Default: `client`. | +| `binName` | string | The command name that the `cli` generator shows in the help output and uses to derive its credential environment variables. The default is the output file name (without extension), sanitized. | +| `cliOutput` | string | The path of a composed CLI entry. The entry includes every api that emits a cli module: from the `cli` generator by name, ejected, or included as a prerequisite. The result is one binary. You address each api by its alias, and each api has `__*` credential variables. This option is available only in the top-level `client` block. See [Compose and extend the CLI](../../guides/use-generated-client.md#compose-and-extend-the-cli). | +| `options` | object | Options for each generator, keyed by generator name. The command validates each entry against the schema that the generator declares. See [Custom generators](../../guides/customize-client-generation.md#custom-generators). | +| `docs` | boolean | Also write the reference documentation for what the run generates: one Markdown page for each selected generator that documents itself (`.cli.md`, `.python.md`, and so on). The `--docs` flag sets it too. Default `false`. | +| `docsFrontmatter` | boolean | Emit YAML front matter carrying the title above each documentation page, for docs sites that expect it. This option is available only in the configuration file. Default `false`. | +| `setup` | string | The path to a publisher setup module that the client includes. The module sets defaults such as the server URL, retries, headers, and middleware. See [Publisher defaults](../../guides/customize-client-generation.md#publisher-defaults). | +| `pagination` | [Pagination object](#pagination-object) | Declares how the API paginates. Paginated operations then get typed `.pages()`/`.items()` async iterators. | ### Pagination object diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index 6c7a1e947e..a700a9420a 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -280,10 +280,49 @@ Each one is a short entry over renderers that are internal to the package, so yo The runnable examples at the end of this page are the model to copy. They use only the public toolkit. -Your generator also receives these hooks. -`run` gets `samples`, the `sample` hook of every selected generator, keyed by generator name. -A generator that writes documentation calls them instead of writing call syntax for a language it does not own. -The built-in `sdk-docs` generator works this way. +### Reference documentation for what you generate + +Implement the optional `docs(input)` hook to return the reference page for your output, with the same `{ path, content }` shape as `run`. +The command calls it only when `client.docs` (or `--docs`) is on, so documentation is one switch for the whole run. + +A generator documents itself, because nothing else knows its call syntax. +The `renderReferencePage(model, options)` helper renders the standard page, and it takes your `sample` hook for the snippets: + +```js +import { defineGenerator, renderReferencePage } from '@redocly/client-generator'; + +const rubyCall = (operation) => ({ lang: 'ruby', source: `client.${operation.name}` }); + +export default defineGenerator({ + name: 'ruby', + run({ model, outputPath }) { + /* the SDK */ + }, + sample: rubyCall, + docs({ model, outputPath, emit }) { + return [ + { + path: outputPath.replace(/\.[^.\\/]+$/, '.ruby.md'), + content: renderReferencePage(model, { + title: `${model.title} Ruby SDK reference`, + frontmatter: emit.docsFrontmatter === true, + language: { + name: 'ruby', + label: 'Ruby', + fence: 'ruby', + requires: 'The SDK needs `faraday`.', + }, + sample: rubyCall, + pagination: emit.pagination, + }), + }, + ]; + }, +}); +``` + +Write your own page instead if the standard layout does not fit: the hook returns files, so the content is yours. +An ejected generator keeps its `docs` hook, so the page layout is ejectable with the generator that owns it. Import-specifier generators execute at generation time. They have the same trust level as any installed dependency that you run. diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index f44f018847..a97598d615 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -22,8 +22,6 @@ Incompatible selections fail immediately with an explanation. | `mock` | `.mocks.ts`: [MSW](https://mswjs.io) v2 handlers and `create` factories. | `msw` `^2` (+ `@faker-js/faker` for `--mock-data faker`) | | `transformers` | `.transformers.ts`: `transform` functions that parse wire dates to `Date`. | none | | `cli` | `.cli.ts`: a [command-line interface](#generated-cli) for the client, ready to use as a bin. It has typed flags, `--json` bodies, env auth, and `--page-all`. | none | -| `cli-docs` | `.cli.md`: a Markdown [reference for the generated CLI](#cli-reference-docs). It lists every command, flag, exit code, and credential variable. | none | -| `sdk-docs` | `..md`: a Markdown [reference for each selected SDK](#sdk-reference-docs). It lists every operation with its parameters and a call sample in that language. | none | ```sh redocly generate-client openapi.yaml --output src/client.ts --generator typescript --generator zod --generator mock @@ -188,33 +186,6 @@ Without this setting, `tsx` reports `Top-level await is currently not supported To ship the CLI as a real bin, compile it with `tsc`. Then point the `bin` field of `package.json` at the compiled file. -#### CLI reference docs - -The `cli-docs` generator writes `.cli.md`, a Markdown reference. -The page contains the usage line, the global flags, the credential environment variables, and the exit-code table. -It also contains one section for each command. -Each section lists the positionals and flags of the command with their types, defaults, and descriptions. -The page renders from the same command table that the CLI dispatches on. -Because of this, the page always matches the tool that it documents. -When you regenerate, the docs update with the tool. -When you select `cli-docs`, the command also selects the CLI that the page describes, so `--generator cli-docs` is enough. - -```sh -redocly generate-client openapi.yaml --output src/client.ts --generator cli-docs -``` - -Two options control the page, under `client.options.cli-docs`: - -| Option | Type | Description | -| ------------- | ------- | ---------------------------------------------------------------------------------------------------------- | -| `title` | string | The page heading. The default is ` CLI`. | -| `frontmatter` | boolean | Emit YAML front matter (`title`) above the heading, for docs sites that expect it. The default is `false`. | - -For a different structure or wording, [eject the generator](../commands/eject-generator.md). -The renderer is the template. -Because of this, `redocly eject-generator cli-docs` gives you the page layout as code that you own, with no template syntax to learn. -The same reference for the language SDKs is next. - ### Language SDKs The `python`, `go`, and `php` generators each emit a full SDK for that language. @@ -348,32 +319,30 @@ As a result, the TypeScript, Python, PHP, and Go clients of an API share one voc The generator reports each rename with its cause. A publisher who wants a different name can rename the schema or the operation in the description. -#### SDK reference docs +### Reference documentation -The `sdk-docs` generator writes one Markdown page for each SDK in the same run. -The page for the `python` generator is `.python.md`, and the page for the `go` generator is `.go.md`. -Each page starts with the API title, the security schemes, and the requirements of that language. -Then it gives one section for each operation. -A section shows the method and path, the parameters, the request body, the response type, and a call sample in that language. +`client.docs: true`, or the `--docs` flag, also writes the reference documentation for what the run generates. +Each generator documents itself, and it writes one Markdown page next to its own output: -The call sample comes from the SDK generator itself, through the same hook that produces `codeSamples`. -Because of this, the page shows the syntax of the SDK next to it, and it cannot drift from that SDK. -Select `sdk-docs` together with at least one SDK generator: `typescript`, `python`, `go`, or `php`. -If you select `sdk-docs` alone, the command stops and tells you to add an SDK generator. +| Generator | Page | Contents | +| --------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------ | +| `cli` | `.cli.md` | The usage line, the global flags, the credential variables, the exit codes, and every command. | +| `typescript` | `.typescript.md` | The security schemes, and every operation with its parameters, body, response type, and a call sample. | +| `python`, `go`, `php` | `..md` | The same page for that SDK, with its own call samples. | ```sh -redocly generate-client openapi.yaml --output src/client.ts --generator python --generator go --generator sdk-docs +redocly generate-client openapi.yaml --output src/client.ts --generator cli --generator python --docs ``` -Two options control the pages, under `client.options.sdk-docs`: - -| Option | Type | Description | -| ------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `title` | string | The page heading. The default is ` SDK reference`. If you select more than one SDK, the generator adds the language to your title. | -| `frontmatter` | boolean | Emit YAML front matter (`title`) above the heading, for docs sites that expect it. The default is `false`. | +One switch covers every language, so a newly documented generator needs no new flag. +A generator that documents nothing, such as `zod`, writes no page. +Each page takes its call samples from the generator's own `sample` hook, so a page shows the syntax of the artifact beside it. +The CLI page renders from the same command table that the CLI dispatches on. +Because of this, a page cannot describe something other than what the run produced. -For a different structure or wording, [eject the generator](../commands/eject-generator.md). -The renderer is the template, the same as for `cli-docs`. +Set `client.docsFrontmatter: true` to put YAML front matter with the title above each page, for docs sites that expect it. +For a different structure or wording, [eject the generator](../commands/eject-generator.md) that owns the page. +The renderer is the template, so an ejected generator keeps writing its page and you own the layout. ## Package runtime diff --git a/packages/client-generator/ARCHITECTURE.md b/packages/client-generator/ARCHITECTURE.md index e6720d364f..df615d42fa 100644 --- a/packages/client-generator/ARCHITECTURE.md +++ b/packages/client-generator/ARCHITECTURE.md @@ -52,7 +52,7 @@ options, decides the output paths, and calls a renderer. The renderer itself liv `emitters/`, because that layer already holds the shared pieces every renderer composes with — `operation-signature.ts` for the calling convention, `ts-type.ts` for schema types, `pagination.ts`, `sse.ts`. `emitters/cli.ts` is there for that reason: `cli` -renders from it, `cli-docs` renders the page from the same command table, and the +renders from it, its `docs` hook renders the reference page from the same command table, and the package entry exports its composed-entry renderer. The three language SDKs are the exception. `python`, `go`, and `php` compose with From 17f946509dc3d40b96a24d5ceb626eb1389f8c96 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 18 Aug 2026 13:36:34 +0300 Subject: [PATCH 190/211] fix(client-generator): derive each sample's module identity from the output path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sample hook cannot know which module to import unless it is told where the run writes, because each language rewrites the --output anchor its own way. So `SampleContext` now carries `outputPath`, threaded from both call sites — the codeSamples overlay and every `docs` hook — and each hook derives its own identity from it. Reported by the review bot: `pythonSample` emitted `from client import Client` while the generator writes `openapi_client.py` for `openapi.client.ts`, and `goSample` hardcoded `client.New` while `goPackage` renames the clause. Checking the other two hooks found the same defect unreported: typescriptSample imported './client', which is the wrong name for most stems and extensionless under ESM resolution, and phpSample required no file at all, so the snippet could not run. Both the documentation pages and the codeSamples overlay read these hooks, so a wrong name shipped in two places. --- .../src/__tests__/code-samples.test.ts | 29 +++++++++++++++++++ .../src/generators/__tests__/cli.test.ts | 8 +++-- .../src/generators/go/index.ts | 6 ++-- .../src/generators/php/index.ts | 6 ++-- .../src/generators/python/index.ts | 11 +++++-- .../client-generator/src/generators/types.ts | 9 ++++-- .../src/generators/typescript/index.ts | 8 +++-- packages/client-generator/src/pipeline.ts | 7 +++-- 8 files changed, 68 insertions(+), 16 deletions(-) diff --git a/packages/client-generator/src/__tests__/code-samples.test.ts b/packages/client-generator/src/__tests__/code-samples.test.ts index b2e39bc107..2c5bfa2814 100644 --- a/packages/client-generator/src/__tests__/code-samples.test.ts +++ b/packages/client-generator/src/__tests__/code-samples.test.ts @@ -53,6 +53,35 @@ describe('codeSamples', () => { } }); + it('imports the module each generator actually writes, not a hardcoded name', async () => { + // A snippet that imports `client` is wrong for every stem the languages rewrite: + // `openapi.client.ts` becomes `openapi_client.py`, and Go qualifies with `goPackage`. + const dir = await mkdtemp(join(tmpdir(), 'code-samples-module-')); + try { + await writeFile(join(dir, 'openapi.yaml'), SPEC); + await generateClient({ + api: join(dir, 'openapi.yaml'), + output: join(dir, 'openapi.client.ts'), + generators: ['typescript', 'python', 'go', 'php'], + goPackage: 'cafe', + codeSamples: true, + }); + const overlay = parseYaml( + await readFile(join(dir, 'openapi.client.code-samples.yaml'), 'utf-8') + ) as Overlay; + const samples = overlay.actions.find((action) => action.target === "$.paths['/pets'].get")! + .update['x-codeSamples'] as Array>; + const sourceOf = (lang: string) => samples.find((sample) => sample.lang === lang)!.source; + + expect(sourceOf('typescript')).toContain("from './openapi.client.js'"); + expect(sourceOf('python')).toContain('from openapi_client import Client'); + expect(sourceOf('php')).toContain("require 'openapi.client.php'"); + expect(sourceOf('go')).toContain('cafe.New(cafe.Config{})'); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + it('emits no overlay file when codeSamples is off', async () => { const dir = await mkdtemp(join(tmpdir(), 'code-samples-off-')); try { diff --git a/packages/client-generator/src/generators/__tests__/cli.test.ts b/packages/client-generator/src/generators/__tests__/cli.test.ts index 68190093e7..d4e241a4f1 100644 --- a/packages/client-generator/src/generators/__tests__/cli.test.ts +++ b/packages/client-generator/src/generators/__tests__/cli.test.ts @@ -84,7 +84,7 @@ describe('cliGenerator', () => { it('renders a shell x-codeSamples snippet per operation, addressed by the group slug', () => { const op = MODEL.services[0].operations[0]; - const sample = cliSample(op, { model: MODEL, emit: {} }); + const sample = cliSample(op, { model: MODEL, emit: {}, outputPath: 'client.ts' }); expect(sample).toMatchObject({ lang: 'shell', label: 'CLI' }); // The CLI dispatches on the slugged group, so the sample must use it — the raw // tag ("Orders", or worse a multi-word one) would not resolve. @@ -102,7 +102,11 @@ describe('cliGenerator', () => { }, ], } as ApiModel; - const sample = cliSample(model.services[0].operations[0], { model, emit: {} }); + const sample = cliSample(model.services[0].operations[0], { + model, + emit: {}, + outputPath: 'client.ts', + }); expect(sample?.source).toContain('coffee-orders getOrder '); }); }); diff --git a/packages/client-generator/src/generators/go/index.ts b/packages/client-generator/src/generators/go/index.ts index 682122eacd..c9f76764b0 100644 --- a/packages/client-generator/src/generators/go/index.ts +++ b/packages/client-generator/src/generators/go/index.ts @@ -1106,6 +1106,8 @@ export const goGenerator: Generator = ({ model, outputPath, emit }) => { /** One idiomatic Go call per operation — feeds `x-codeSamples` for docs. */ export function goSample(op: OperationModel, ctx: SampleContext): CodeSample { const dateType = ctx.emit.dateType ?? 'string'; + // `goPackage` renames the package clause, and the snippet qualifies with it. + const pkg = ctx.emit.goPackage ?? 'client'; const ident = exported(op.name); const args = [ 'ctx', @@ -1118,7 +1120,7 @@ export function goSample(op: OperationModel, ctx: SampleContext): CodeSample { return { lang: 'go', label: 'Go SDK', - source: `client := client.New(client.Config{})\nresult, err := client.${ident}(${args.join(', ')})\n`, + source: `client := ${pkg}.New(${pkg}.Config{})\nresult, err := client.${ident}(${args.join(', ')})\n`, }; } @@ -1139,7 +1141,7 @@ export const goDocs: Generator = ({ model, outputPath, emit }) => [ fence: 'go', requires: 'The SDK needs the standard library only.', }, - sample: (op) => goSample(op, { model, emit }), + sample: (op) => goSample(op, { model, emit, outputPath }), pagination: emit.pagination, }), }, diff --git a/packages/client-generator/src/generators/php/index.ts b/packages/client-generator/src/generators/php/index.ts index aa4c538176..d29b505379 100644 --- a/packages/client-generator/src/generators/php/index.ts +++ b/packages/client-generator/src/generators/php/index.ts @@ -1043,10 +1043,12 @@ export function phpSample(op: OperationModel, ctx: SampleContext): CodeSample { : []), ]; 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: `use ${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->${methodName(op)}(${args.join(', ')});\n`, }; } @@ -1067,7 +1069,7 @@ export const phpDocs: Generator = ({ model, outputPath, emit }) => [ fence: 'php', requires: 'The SDK needs the curl extension.', }, - sample: (op) => phpSample(op, { model, emit }), + sample: (op) => phpSample(op, { model, emit, outputPath }), pagination: emit.pagination, }), }, diff --git a/packages/client-generator/src/generators/python/index.ts b/packages/client-generator/src/generators/python/index.ts index 786f9ef9b7..5883f0e817 100644 --- a/packages/client-generator/src/generators/python/index.ts +++ b/packages/client-generator/src/generators/python/index.ts @@ -749,7 +749,12 @@ export const pythonGenerator: Generator = ({ model, outputPath, emit }) => { }; /** One idiomatic Python call per operation — feeds `x-codeSamples` for docs. */ -export function pythonSample(op: OperationModel, _ctx: SampleContext): CodeSample { +export function pythonSample(op: OperationModel, ctx: SampleContext): CodeSample { + // The module name this run writes, not a guess: `openapi.client.ts` becomes + // `openapi_client.py`, so `from client import Client` would not import. + const module = pythonModulePath(ctx.outputPath) + .replace(/^.*[\\/]/, '') + .replace(/\.py$/, ''); const ident = identifierFor(op.name, { style: 'snake', reserved: PY }); const args = [ ...op.pathParams.map((param) => { @@ -767,7 +772,7 @@ export function pythonSample(op: OperationModel, _ctx: SampleContext): CodeSampl return { lang: 'python', label: 'Python SDK', - source: `from client import Client\n\nclient = Client()\nresult = client.${ident}(${args.join(', ')})\n`, + source: `from ${module} import Client\n\nclient = Client()\nresult = client.${ident}(${args.join(', ')})\n`, }; } @@ -788,7 +793,7 @@ export const pythonDocs: Generator = ({ model, outputPath, emit }) => [ fence: 'python', requires: 'The SDK needs `httpx`.', }, - sample: (op) => pythonSample(op, { model, emit }), + sample: (op) => pythonSample(op, { model, emit, outputPath }), pagination: emit.pagination, }), }, diff --git a/packages/client-generator/src/generators/types.ts b/packages/client-generator/src/generators/types.ts index cb8c74dba4..331d26d34a 100644 --- a/packages/client-generator/src/generators/types.ts +++ b/packages/client-generator/src/generators/types.ts @@ -81,8 +81,13 @@ export type Generator = (input: GeneratorInput) => GeneratedFile[]; /** One idiomatic call snippet for an operation, rendered for docs (`x-codeSamples`). */ export type CodeSample = { lang: string; label?: string; source: string }; -/** What a `sample` hook receives besides the operation. */ -export type SampleContext = { model: ApiModel; emit: EmitOptions }; +/** + * What a `sample` hook receives besides the operation. `outputPath` is the `--output` + * anchor: a snippet has to import the module this run actually writes, and each language + * derives that name from the anchor its own way (`openapi.client.ts` becomes + * `openapi_client.py`), so a hardcoded module name is wrong for most stems. + */ +export type SampleContext = { model: ApiModel; emit: EmitOptions; outputPath: string }; /** * A generator plus its declared compatibility contract. `validateGenerators` diff --git a/packages/client-generator/src/generators/typescript/index.ts b/packages/client-generator/src/generators/typescript/index.ts index 0b40b84678..6be1097535 100644 --- a/packages/client-generator/src/generators/typescript/index.ts +++ b/packages/client-generator/src/generators/typescript/index.ts @@ -47,7 +47,7 @@ export const typescriptDocs: Generator = ({ model, outputPath, emit }) => [ fence: 'typescript', requires: 'The client has no dependencies.', }, - sample: (op) => typescriptSample(op, { model, emit }), + sample: (op) => typescriptSample(op, { model, emit, outputPath }), pagination: emit.pagination, }), }, @@ -56,6 +56,10 @@ export const typescriptDocs: Generator = ({ model, outputPath, emit }) => [ /** One idiomatic TS call per operation, for `x-codeSamples` and the SDK reference pages. */ export function typescriptSample(op: OperationModel, ctx: SampleContext): CodeSample { const ident = packageIdents(ctx.model).get(op.name) ?? op.name; + // The module this run writes, with the run's import extension — `./client` would be + // both the wrong name for most stems and extensionless under ESM resolution. + const stem = ctx.outputPath.replace(/^.*[\\/]/, '').replace(/\.[^.]+$/, ''); + const specifier = `./${stem}.${ctx.emit.importExt ?? 'js'}`; const requiredQuery = op.queryParams.filter((param) => param.required); const slots: string[] = []; if (requiredQuery.length > 0) { @@ -78,6 +82,6 @@ export function typescriptSample(op: OperationModel, ctx: SampleContext): CodeSa return { lang: 'typescript', label: 'TypeScript SDK', - source: `import { ${ident} } from './client';\n\nconst result = await ${ident}(${args.join(', ')});\n`, + source: `import { ${ident} } from '${specifier}';\n\nconst result = await ${ident}(${args.join(', ')});\n`, }; } diff --git a/packages/client-generator/src/pipeline.ts b/packages/client-generator/src/pipeline.ts index 12990d9379..f2b8a732e5 100644 --- a/packages/client-generator/src/pipeline.ts +++ b/packages/client-generator/src/pipeline.ts @@ -121,12 +121,13 @@ function codeSamplesOverlay( model: ApiModel, emit: EmitOptions, selected: string[], - registry: Map + registry: Map, + outputPath: string ): string | undefined { const actions = []; for (const op of allOperations(model.services)) { const samples = selected - .map((name) => registry.get(name)?.sample?.(op, { model, emit })) + .map((name) => registry.get(name)?.sample?.(op, { model, emit, outputPath })) .filter((sample): sample is CodeSample => sample !== undefined); if (samples.length > 0) { actions.push({ @@ -229,7 +230,7 @@ export async function generateClient( }); if (options.codeSamples === true) { - const overlay = codeSamplesOverlay(model, emit, selected, registry); + const overlay = codeSamplesOverlay(model, emit, selected, registry, outputPath); if (overlay !== undefined) { files.push({ path: outputPath.replace(/\.[^.]+$/, '.code-samples.yaml'), content: overlay }); } From 2082d015690a70ccfa47d1e74ca799443bd71098 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 18 Aug 2026 13:43:33 +0300 Subject: [PATCH 191/211] docs: lead the CLI syntax with the operationId, not the tag group MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review asked why a tag is needed to address a command. It is not: a bare operationId resolves whenever it is unambiguous, and the docs simply taught the longer form first. The guide and the cli example now lead with ` listOrders`, and show the tag slug as what resolves an ambiguous name. The guide also says what a tag group is still for — organizing `--help` for an API with hundreds of operations — and that an operation without an operationId still gets a command, named from its method and path (`GET /pets` becomes `getPets`), so a description that declares none still has a complete CLI. --- docs/@v2/guides/use-generated-client.md | 27 ++++++++++--------- .../generate-client/examples/cli/README.md | 6 ++--- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index a97598d615..3671691300 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -47,17 +47,21 @@ Install zod next to the generated CLI (`npm i zod`). ```sh redocly generate-client openapi.yaml --output src/client.ts --generator typescript --generator cli -npx tsx src/client.cli.ts orders listOrders --status open --limit 10 -npx tsx src/client.cli.ts orders createOrder --json @order.json -npx tsx src/client.cli.ts orders listOrders --page-all # one JSON page per line +npx tsx src/client.cli.ts listOrders --status open --limit 10 +npx tsx src/client.cli.ts createOrder --json @order.json +npx tsx src/client.cli.ts listOrders --page-all # one JSON page per line npx tsx src/client.cli.ts schema createOrder # the operation's full contract ``` -`--help` lists the commands. -For tagged APIs, the commands are grouped. -Run ` --help` to show the flags of one command. -An operationId also works without its group (` listOrders`) when the operationId is unambiguous. -Because of this, you do not have to know its group. +**The operationId alone is the command**: ` listOrders`. +You do not have to know which tag the operation carries. +Run ` listOrders --help` to show the flags of one command. + +A tag adds a group, and a group does two things. +It organizes `--help`, which matters for an API with hundreds of operations. +It also disambiguates: if two operations share a command name, the CLI reports the ambiguity and names the groups to choose from, and ` orders listOrders` addresses one of them. +An operation with no `operationId` still gets a command. +The generator derives the name from the method and the path (`GET /pets` becomes `getPets`, and `GET /pets/{id}` becomes `getPetsId`), so a description without operationIds has a complete CLI. Group names and command names use different cases, and this is deliberate. A group name comes from an OpenAPI tag, which is prose. @@ -130,10 +134,9 @@ npx tsx src/cafe.ts kitchen createOrder --json @o.json # CAFE_KITCHEN_TOKEN ``` Two different things can stand in the word after the bin name, so compare the two setups. -For one API, that word is the tag slug: `cafe orders listOrders`. -For a composed binary, that word is the api alias, and the tag groups of that api nest inside it: `cafe shop orders listOrders`. -The example above is shorter than that, because a bare operationId resolves whenever it is unambiguous. -If two tags of one api declare the same operationId, the CLI reports the ambiguity and names the groups to choose from. +For one API, an operationId is the whole command (`cafe listOrders`), and a tag slug goes in front of it only to resolve an ambiguous name (`cafe orders listOrders`). +For a composed binary, that first word is the api alias, because an operationId is unique only inside one description: `cafe shop listOrders`. +A tag group of that api nests inside its alias, again only when it is needed: `cafe shop orders listOrders`. An operationId is unique only inside one description. Because of this, each command carries its api's alias as a namespace. diff --git a/tests/e2e/generate-client/examples/cli/README.md b/tests/e2e/generate-client/examples/cli/README.md index f3303d6074..5cc116e1b4 100644 --- a/tests/e2e/generate-client/examples/cli/README.md +++ b/tests/e2e/generate-client/examples/cli/README.md @@ -12,9 +12,9 @@ Generate the client, then drive the API from the shell: npm run generate npx tsx src/api/client.cli.ts --help -npx tsx src/api/client.cli.ts products listMenuItems --limit 3 -npx tsx src/api/client.cli.ts listMenuItems --limit 3 # the group is optional when unambiguous -npx tsx src/api/client.cli.ts orders createOrder --json @order.json --dry-run +npx tsx src/api/client.cli.ts listMenuItems --limit 3 +npx tsx src/api/client.cli.ts createOrder --json @order.json --dry-run +npx tsx src/api/client.cli.ts products listMenuItems --limit 3 # the tag group, for an ambiguous name npx tsx src/api/client.cli.ts schema createOrder ``` From 187ebb8e31dadecd6e32ccc93e8ab69e9048e6b2 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 18 Aug 2026 14:36:59 +0300 Subject: [PATCH 192/211] docs: add a quickstart, a migration guide, and the HTTP-layer seam Three gaps a reader hits before anything else. The command page opened with five paragraphs of orientation, so a reader had to read before running anything. It now opens with a quickstart: one npx line, the code it produces, and the flag list for the rest. Nothing told an app with a hand-written client how to move. A new guide does: generate beside the old client, a table mapping each hand-written piece to its replacement, how to migrate call sites, what to do when the description turns out to be wrong, and how to migrate the tests instead of mocking the client away. Nothing documented `ClientConfig.fetch`, so "can it go through my configured request library?" had no answer in the docs. The usage guide now has an HTTP layer section with an adapter example, and says to prefer middleware for behavior that belongs to the API. --- docs/@v2/commands/generate-client.md | 31 +++++ .../@v2/guides/migrate-to-generated-client.md | 125 ++++++++++++++++++ docs/@v2/guides/use-generated-client.md | 36 +++++ docs/@v2/v2.sidebars.yaml | 2 + 4 files changed, 194 insertions(+) create mode 100644 docs/@v2/guides/migrate-to-generated-client.md diff --git a/docs/@v2/commands/generate-client.md b/docs/@v2/commands/generate-client.md index d122771646..e3d2309690 100644 --- a/docs/@v2/commands/generate-client.md +++ b/docs/@v2/commands/generate-client.md @@ -6,6 +6,36 @@ Its flags, generated output, configuration schema, and custom-generator API can Send us your feedback while we stabilize the feature. {% /admonition %} +## Quickstart + +Point the command at a description and give it an output path: + +```bash +npx @redocly/cli generate-client openapi.yaml --output src/client.ts +``` + +That writes one self-contained file with a typed function for each operation: + +```ts +import { listOrders, createOrder, configure } from './client.js'; + +configure({ auth: { bearer: process.env.API_TOKEN } }); + +const orders = await listOrders({ status: 'open', limit: 10 }); +const created = await createOrder({ items: [{ menuItemId: 'itm_1', quantity: 2 }] }); +``` + +The client has no dependencies, and it carries the behavior an API needs: auth for every scheme the description declares, opt-in retries, timeouts, middleware, pagination iterators, and typed server-sent events. +Add a flag for each extra artifact you want: + +```bash +npx @redocly/cli generate-client openapi.yaml -o src/client.ts \ + --generator zod --generator tanstack-query --generator mock --docs +``` + +The rest of this page describes the flags. +[Use the generated client](../guides/use-generated-client.md) describes what the output does. + ## Introduction The `generate-client` command generates a typed TypeScript client from an OpenAPI 3.x description. @@ -132,6 +162,7 @@ See [Package runtime](../guides/use-generated-client.md#package-runtime) in the ## Resources - **[Use the generated client](../guides/use-generated-client.md)** - Learn how to use the client produced by the `generate-client` command +- **[Move an app to a generated client](../guides/migrate-to-generated-client.md)** - Replace a hand-written client, one call site at a time - **[`client` configuration](../configuration/reference/client.md)** - Explore the settings for the `generate-client` command - **[Lint command](./lint.md)** - Validate your API description before you generate a client - **[Bundle command](./bundle.md)** - Combine a multi-file description into one input file diff --git a/docs/@v2/guides/migrate-to-generated-client.md b/docs/@v2/guides/migrate-to-generated-client.md new file mode 100644 index 0000000000..8ec1d2d160 --- /dev/null +++ b/docs/@v2/guides/migrate-to-generated-client.md @@ -0,0 +1,125 @@ +# Move an existing app to a generated client + +## Introduction + +Most applications already talk to their API through code somebody wrote by hand: a types file, a `fetch` wrapper, and a set of helpers around them. +This guide tells you how to replace that code with a generated client, one API at a time, without a rewrite. + +It assumes you have an OpenAPI description of the API. +If the description is out of date, read [Expect the description to be wrong](#expect-the-description-to-be-wrong) first, because that step decides how the rest of the work feels. + +## Generate beside your current client + +Generate into a new path and change nothing else: + +```bash +npx @redocly/cli generate-client openapi.yaml --output src/api/generated/client.ts +``` + +Your application still runs on the old code. +You now have both, so you can compare them and migrate one call site at a time. + +Put the command in your build so the client cannot drift from the description: + +```json +{ + "scripts": { + "generate": "redocly generate-client openapi.yaml -o src/api/generated/client.ts", + "build": "npm run generate && tsc" + } +} +``` + +Commit the generated file. +A reviewer then sees what changed in the API when you regenerate, and the build does not depend on the description being reachable. + +## Map your old client onto the new one + +The pieces of a hand-written client have direct equivalents: + +| What you have now | What replaces it | +| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| A types file, generated or hand-written | The types in the generated client. Every operation carries its own request and response types. | +| A `fetch` wrapper with a base URL | `configure({ serverUrl })`, or the `servers` entry of the description. | +| Auth headers added by hand | `configure({ auth: … })`, or the generated setter for each scheme the description declares. See [Authentication](./use-generated-client.md#authentication). | +| A retry helper | `configure({ retry: { retries: 3 } })`. See [Retries](./use-generated-client.md#retries). | +| A hand-rolled pagination loop | Declared [pagination](./use-generated-client.md#pagination), then `.pages()` and `.items()` iterators. | +| Interceptors for logs, traces, or headers | [Middleware](./use-generated-client.md#middleware), which sees each operation's id and tags as literal types. | +| An existing configured request library | `configure({ fetch })`. See [The HTTP layer](./use-generated-client.md#the-http-layer). | +| Response shapes checked by hand | The [`zod` generator](./use-generated-client.md#runtime-validation) and its `zodValidation()` middleware. | +| Hand-written API mocks in tests | The [`mock` generator](./use-generated-client.md#generators): MSW handlers and typed data factories. | + +Two of those replace whole files rather than lines. +Pagination loops and mock fixtures are usually the largest deletions in a migration of this kind. + +## Migrate the call sites + +Work per module, not per operation. +For each module, change the imports to the generated client and let the compiler list what breaks: + +```ts +// Before +import { getOrder } from '../api/orders'; +const order = await getOrder(orderId); + +// After +import { getOrderById } from '../api/generated/client.js'; +const order = await getOrderById(orderId); +``` + +Three differences account for most of the compiler errors: + +- **Operation names come from the description.** The generated name is the `operationId`, so `getOrder` becomes whatever the description calls it. + If the names read badly, fix them in the description: every consumer improves at once. +- **Inputs have named slots.** Query parameters go in `params`, the body in `body`, headers in `headers`. + A call that passes an undeclared key fails with a `TypeError` that names the key, so a wrong call cannot reach the network. + [`--args-style grouped`](../commands/generate-client.md#options) puts every input in one object, which reads better at large call sites. +- **Errors are typed.** By default an operation throws `ApiError` on a non-2xx response. + With [`--error-mode result`](./use-generated-client.md#error-handling) it returns `{ data, error, response }` instead, which is closer to some hand-written wrappers. + +## Expect the description to be wrong + +A generated client holds your code to the description, so the first run tells you where the two disagree. +This is the useful part of the migration, and it is also the part that surprises people, so plan for it. + +Turn on runtime validation early: + +```ts +import { use } from './api/generated/client.ts'; +import { zodValidation } from './api/generated/client.zod.ts'; + +use(zodValidation()); // invalid requests throw; response drift warns +``` + +Requests that do not match the description throw before the network call, and responses that do not match warn by default. +Both point at the field and the operation. + +When a check fails, fix the cause rather than the check. +A failure is either a defect in your code or a defect in the description, and disabling validation keeps both. +Correct the description, regenerate, and every consumer of that API gets the correction. + +## Migrate the tests too + +A generated client that every test mocks away is a generated client that no test exercises. +The `mock` generator emits MSW handlers and typed factories, so a test can run the real client against a fake network: + +```ts +import { listOrdersHandler, createOrder } from './api/generated/client.mocks.ts'; + +server.use(listOrdersHandler({ orders: [createOrder({ id: 'ord_1' })] })); +// the code under test now issues a real request through the real client +``` + +This moves argument building, URL construction, and response parsing into the test, which is where the migration's remaining defects usually hide. + +## Delete the old client + +Remove the old module when its last call site is gone, and keep the deletion in its own commit. +The generated client replaces code rather than adding a layer, so the net line count of a migration is usually negative. + +## Resources + +- [`generate-client` command](../commands/generate-client.md): the flags and the invocation. +- [Use the generated client](./use-generated-client.md): auth, retries, middleware, pagination, and the add-on generators. +- [Customize client generation](./customize-client-generation.md): publisher defaults, custom generators, and ejecting a built-in generator. +- [`client` configuration](../configuration/reference/client.md): the `redocly.yaml` block. diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index 3671691300..8fb0981cc4 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -573,6 +573,41 @@ But prefer `use()` to add to existing middleware, including [publisher pre-confi See the [`configure-and-middleware` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/configure-and-middleware) for a runnable version. +## The HTTP layer + +The client sends requests with `fetch`, and that is the only transport it needs. +Auth, retries, timeouts, and middleware are part of the client, so you do not add a request library to get them. + +If your application already has a configured HTTP layer, pass it to the client instead of replacing what you have. +`ClientConfig.fetch` accepts anything with the `fetch` signature, so an existing instance of your request library goes in through one adapter function: + +```ts +import axios from 'axios'; +import { configure } from './client.ts'; + +// One adapter, and every generated call goes through your instance: +// its interceptors, its base configuration, its telemetry. +configure({ + fetch: async (input, init) => { + const response = await axios.request({ + url: typeof input === 'string' ? input : input.toString(), + method: init?.method ?? 'GET', + headers: init?.headers as Record, + data: init?.body, + responseType: 'text', + validateStatus: () => true, + }); + return new Response(response.data, { + status: response.status, + headers: response.headers as HeadersInit, + }); + }, +}); +``` + +The same seam takes a test double, a proxy-aware fetch, or a `fetch` that adds tracing headers. +Prefer [middleware](#middleware) for behavior that belongs to your API, and keep `fetch` for the transport itself. + ## Retries Retry is **opt-in**. @@ -963,3 +998,4 @@ That is a generator bug, not a style choice. - **[`generate-client` command](../commands/generate-client.md)** — Learn about the the `generate-client` command's flags, output modes, and invocation - **[`client` configuration](../configuration/reference/client.md)** — Explore the settings for the `generate-client` command - **[Customize client generation](./customize-client-generation.md)** — Learn how to control the output of the `generate-client` +- **[Move an app to a generated client](./migrate-to-generated-client.md)** — Replace a hand-written client, one call site at a time diff --git a/docs/@v2/v2.sidebars.yaml b/docs/@v2/v2.sidebars.yaml index 08f7f39d63..0069791079 100644 --- a/docs/@v2/v2.sidebars.yaml +++ b/docs/@v2/v2.sidebars.yaml @@ -66,6 +66,8 @@ label: Lint and bundle - label: Use the generated client page: guides/use-generated-client.md + - label: Move an app to a generated client + page: guides/migrate-to-generated-client.md - label: Customize client generation page: guides/customize-client-generation.md - label: Hide internal APIs From 991b3ac54243ab248e44f30e8a2f8b34ba53d2ef Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 18 Aug 2026 14:38:56 +0300 Subject: [PATCH 193/211] docs: use the plain redocly command in the client generation examples The quickstart and the migration guide invoked the command through `npx @redocly/cli`, which no other generate-client example does. Every command page assumes an installed `redocly`, and the installation page already covers the npx form. --- docs/@v2/commands/generate-client.md | 4 ++-- docs/@v2/guides/migrate-to-generated-client.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/@v2/commands/generate-client.md b/docs/@v2/commands/generate-client.md index e3d2309690..c6135ac9c5 100644 --- a/docs/@v2/commands/generate-client.md +++ b/docs/@v2/commands/generate-client.md @@ -11,7 +11,7 @@ Send us your feedback while we stabilize the feature. Point the command at a description and give it an output path: ```bash -npx @redocly/cli generate-client openapi.yaml --output src/client.ts +redocly generate-client openapi.yaml --output src/client.ts ``` That writes one self-contained file with a typed function for each operation: @@ -29,7 +29,7 @@ The client has no dependencies, and it carries the behavior an API needs: auth f Add a flag for each extra artifact you want: ```bash -npx @redocly/cli generate-client openapi.yaml -o src/client.ts \ +redocly generate-client openapi.yaml -o src/client.ts \ --generator zod --generator tanstack-query --generator mock --docs ``` diff --git a/docs/@v2/guides/migrate-to-generated-client.md b/docs/@v2/guides/migrate-to-generated-client.md index 8ec1d2d160..5260a557b8 100644 --- a/docs/@v2/guides/migrate-to-generated-client.md +++ b/docs/@v2/guides/migrate-to-generated-client.md @@ -13,7 +13,7 @@ If the description is out of date, read [Expect the description to be wrong](#ex Generate into a new path and change nothing else: ```bash -npx @redocly/cli generate-client openapi.yaml --output src/api/generated/client.ts +redocly generate-client openapi.yaml --output src/api/generated/client.ts ``` Your application still runs on the old code. From c819c4fd6364e3da1e43a29fa70580e43c4211b1 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 18 Aug 2026 16:32:41 +0300 Subject: [PATCH 194/211] feat(client-generator): emit pydantic models for the python SDK on request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `client.options.python.models: pydantic` emits `BaseModel` classes instead of dataclasses, for the half of the Python ecosystem that expects them. A wire name that is not a legal field name becomes `Field(alias=…)` with `populate_by_name=True`, so `_field_map` and its `ClassVar` import are not emitted in that mode. Nothing else changes: the same class names, the same field names, the same client, the same runtime. Switching modes does not touch a call site. One runtime serves both modes. `_decode.py` dispatches on the target — a class with `model_validate` is validated by pydantic, a dataclass is hydrated reflectively — and `encode` mirrors it with `model_dump(by_alias=True, exclude_none=True, mode="json")`. A second runtime variant would double the surface that has to stay in step, and pydantic's `ValidationError` already subclasses `ValueError`, so probing union members needs no new except clause. The mode adds a dependency, so the generated header asks for `httpx pydantic` instead of letting the import fail with nothing to act on. CI installs pydantic so the round-trip bar runs rather than skips. --- .changeset/agent-friendly-generators.md | 2 +- .github/workflows/tests.yaml | 4 +- docs/@v2/configuration/reference/client.md | 2 +- .../skills/python-generator/SKILL.md | 19 ++++- .../runtime/python/_decode.py | 20 +++-- .../src/emitters/python-runtime-sources.ts | 2 +- .../src/generators/__tests__/python.test.ts | 27 ++++++ .../client-generator/src/generators/meta.ts | 1 + .../src/generators/python/AGENTS.md | 19 ++++- .../src/generators/python/index.ts | 79 +++++++++++++---- tests/e2e/generate-client/python.test.ts | 84 ++++++++++++++++++- 11 files changed, 229 insertions(+), 30 deletions(-) diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index 302eac91e9..b428973f2f 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -9,7 +9,7 @@ Added `--docs` (`client.docs`), which writes the reference documentation for wha Added composable generated CLIs (custom commands, one binary over several APIs via `client.cliOutput`). -Added language-neutral authoring toolkit with per-generator options. +Added language-neutral authoring toolkit with per-generator options, including `client.options.python.models: pydantic`, which emits `BaseModel` classes instead of dataclasses. Added an `eject-generator` command that vendors any built-in generator, with its design as an agent skill, into your repo. diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index e36a6a66c2..bd171a554b 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -69,8 +69,8 @@ jobs: - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: '3.12' - - name: Install httpx (the Python import bars need it) - run: pip install httpx + - name: Install httpx and pydantic (the Python import bars need them) + run: pip install httpx pydantic # Go, gofmt, and php come with the runner image; a bar whose toolchain is missing # skips itself, so a thinner image degrades coverage instead of failing the job. - name: Cache the pinned GitHub REST description diff --git a/docs/@v2/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md index 6b38496387..2fa137b205 100644 --- a/docs/@v2/configuration/reference/client.md +++ b/docs/@v2/configuration/reference/client.md @@ -39,7 +39,7 @@ As an alternative, pass `pagination` to the programmatic `generateClient(...)`. | `goPackage` | string | The package clause for the output of the `go` generator. The value must be a valid Go package name: lowercase letters, digits, and `_`, with no digit at the start, and not a keyword. An invalid value stops generation, so the generator does not emit a file that Go cannot compile. Default: `client`. | | `binName` | string | The command name that the `cli` generator shows in the help output and uses to derive its credential environment variables. The default is the output file name (without extension), sanitized. | | `cliOutput` | string | The path of a composed CLI entry. The entry includes every api that emits a cli module: from the `cli` generator by name, ejected, or included as a prerequisite. The result is one binary. You address each api by its alias, and each api has `__*` credential variables. This option is available only in the top-level `client` block. See [Compose and extend the CLI](../../guides/use-generated-client.md#compose-and-extend-the-cli). | -| `options` | object | Options for each generator, keyed by generator name. The command validates each entry against the schema that the generator declares. See [Custom generators](../../guides/customize-client-generation.md#custom-generators). | +| `options` | object | Options for each generator, keyed by generator name. The command validates each entry against the schema that the generator declares. The `python` generator accepts `models`: `dataclass` (default) or `pydantic`. See [Custom generators](../../guides/customize-client-generation.md#custom-generators). | | `docs` | boolean | Also write the reference documentation for what the run generates: one Markdown page for each selected generator that documents itself (`.cli.md`, `.python.md`, and so on). The `--docs` flag sets it too. Default `false`. | | `docsFrontmatter` | boolean | Emit YAML front matter carrying the title above each documentation page, for docs sites that expect it. This option is available only in the configuration file. Default `false`. | | `setup` | string | The path to a publisher setup module that the client includes. The module sets defaults such as the server URL, retries, headers, and middleware. See [Publisher defaults](../../guides/customize-client-generation.md#publisher-defaults). | 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 9f7db9d6c2..e6ad40f3db 100644 --- a/packages/client-generator/eject-assets/skills/python-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/python-generator/SKILL.md @@ -23,9 +23,24 @@ One self-contained `.py`: typed dataclass models, a sync `Client` and an a `identifierFor(stem, snake)`, so `rebilly-core.client.ts` emits `rebilly_core_client.py` and `import rebilly_core_client` just works. -- **Models are dataclasses**, required fields first (a dataclass constraint), optionals - `Optional[T] = None`. Wire names live in a `_field_map: ClassVar[Dict[str, str]]`; +- **Models are dataclasses by default**, required fields first (a dataclass constraint), + optionals `Optional[T] = None`. Wire names live in a `_field_map: ClassVar[Dict[str, str]]`; decode/encode is reflective (`_decode.py`, `get_type_hints`) — no per-model codecs. +- **`models: pydantic` emits `BaseModel` classes instead**, for the FastAPI-shaped half of + the ecosystem that expects them. A wire name becomes `Field(alias=…)` with + `populate_by_name=True`, so `_field_map` is not emitted in this mode — the alias is the + mapping. Everything else is unchanged: the same class names, the same field names, the + same `Optional[T] = None`, the same enums and union aliases, the same client and runtime. + Switching modes must not change a call site. +- **One runtime serves both model modes.** `_decode.py` dispatches on the target: a class + with `model_validate` is validated by pydantic, a dataclass is hydrated reflectively, and + `encode` mirrors that with `model_dump(by_alias=True, exclude_none=True, mode="json")`. + A second runtime variant per mode would double the surface that has to stay in step, and + pydantic's `ValidationError` already subclasses `ValueError`, so union member probing + needs no new except clause. +- **`models: pydantic` adds a dependency, and the header says so.** The default mode keeps + httpx as the only requirement; the pydantic header asks for both. A mode that quietly + needed a package the file never named would fail at import with nothing to act on. - **Naming:** fields/methods snake*case via `identifierFor(..., RESERVED_WORDS.python)`; reserved words get a trailing underscore (`class*`); `+1`/`-1`become`plus_1`/`minus_1`. - **Enums** are `class X(str, Enum)` with SCREAMING members; **unions** are `Union[...]` diff --git a/packages/client-generator/runtime/python/_decode.py b/packages/client-generator/runtime/python/_decode.py index 1270399063..04abc99bb2 100644 --- a/packages/client-generator/runtime/python/_decode.py +++ b/packages/client-generator/runtime/python/_decode.py @@ -1,8 +1,10 @@ -# Reflective JSON <-> dataclass conversion for generated Python clients. The -# generated models are plain dataclasses; this decoder hydrates parsed JSON into -# them (and encode() mirrors back to wire shape), honoring each class's -# `_field_map` (python name -> wire name) and typing constructs the generator -# emits: Optional/Union, List, Dict, Enum, Literal, Any. +# Reflective JSON <-> model conversion for generated Python clients. Models are +# plain dataclasses by default, or pydantic BaseModels under `models: pydantic`; +# one decoder serves both. For a dataclass it hydrates parsed JSON reflectively, +# honoring each class's `_field_map` (python name -> wire name) and the typing +# constructs the generator emits: Optional/Union, List, Dict, Enum, Literal, Any. +# For a pydantic model it defers to pydantic, which already knows the aliases. +# encode() mirrors whichever it was given back to wire shape. from __future__ import annotations import dataclasses @@ -66,6 +68,10 @@ def decode(type_: Any, data: Any): ) except ValueError: return data + # A pydantic model validates itself, aliases included. `ValidationError` + # subclasses `ValueError`, so union member probing above still works. + if isinstance(type_, type) and hasattr(type_, "model_validate"): + return type_.model_validate(data) if dataclasses.is_dataclass(type_): hints = get_type_hints(type_) field_map = getattr(type_, "_field_map", {}) @@ -80,6 +86,10 @@ def decode(type_: Any, data: Any): def encode(value: Any): """Python shape -> wire (JSON) shape; inverse of decode for request bodies.""" + # `mode="json"` resolves datetimes and enums the same way the branches below do, + # and `exclude_none` matches the dataclass path: an unset optional is not sent. + if hasattr(value, "model_dump") and not isinstance(value, type): + return value.model_dump(by_alias=True, exclude_none=True, mode="json") if dataclasses.is_dataclass(value) and not isinstance(value, type): field_map = getattr(type(value), "_field_map", {}) out = {} diff --git a/packages/client-generator/src/emitters/python-runtime-sources.ts b/packages/client-generator/src/emitters/python-runtime-sources.ts index 0ae70156cb..c83f0dae73 100644 --- a/packages/client-generator/src/emitters/python-runtime-sources.ts +++ b/packages/client-generator/src/emitters/python-runtime-sources.ts @@ -7,7 +7,7 @@ export const PYTHON_RUNTIME_SOURCES = { '_url.py': '# URL assembly for generated Python clients — path-parameter substitution with\n# percent-encoding, mirroring the TypeScript runtime\'s url.ts semantics.\nfrom __future__ import annotations\n\nfrom typing import Any, Dict\nfrom urllib.parse import quote\n\n\ndef build_url(server_url: str, path: str, path_params: Dict[str, Any]) -> str:\n filled = path\n for name, value in path_params.items():\n filled = filled.replace("{" + name + "}", quote(str(value), safe=""))\n return server_url.rstrip("/") + filled\n', '_decode.py': - '# Reflective JSON <-> dataclass conversion for generated Python clients. The\n# generated models are plain dataclasses; this decoder hydrates parsed JSON into\n# them (and encode() mirrors back to wire shape), honoring each class\'s\n# `_field_map` (python name -> wire name) and typing constructs the generator\n# emits: Optional/Union, List, Dict, Enum, Literal, Any.\nfrom __future__ import annotations\n\nimport dataclasses\nimport typing\nfrom datetime import date, datetime\nfrom enum import Enum\nfrom typing import Any, Dict, Tuple, get_args, get_origin, get_type_hints\n\n# Discriminated unions: resolved Union annotation -> (wire property, {value: class}).\n# The generated module registers its unions here; decode() dispatches through it\n# before falling back to trying members in order.\nDISCRIMINATORS: Dict[Any, Tuple[str, Dict[str, Any]]] = {}\n\n\ndef decode(type_: Any, data: Any):\n """Best-effort hydration: wire data -> the annotated Python shape. Unknown or\n mismatched shapes pass through unchanged (the server is the source of truth)."""\n if data is None or type_ is Any or type_ is None:\n return data\n origin = get_origin(type_)\n if origin is typing.Union:\n discriminator = DISCRIMINATORS.get(type_)\n if discriminator is not None and isinstance(data, dict):\n wire_property, mapping = discriminator\n target = mapping.get(data.get(wire_property))\n if target is not None:\n try:\n return decode(target, data)\n except (TypeError, ValueError, KeyError):\n pass\n for member in get_args(type_):\n if member is type(None):\n continue\n try:\n return decode(member, data)\n except (TypeError, ValueError, KeyError):\n continue\n return data\n if origin is list:\n (item_type,) = get_args(type_) or (Any,)\n return [decode(item_type, item) for item in data]\n if origin is dict:\n args = get_args(type_)\n value_type = args[1] if len(args) == 2 else Any\n return {key: decode(value_type, value) for key, value in data.items()}\n if origin is typing.Literal:\n return data\n if isinstance(type_, type) and issubclass(type_, Enum):\n return type_(data)\n # `dateType: Date` annotates date/date-time fields as datetime objects; a value that\n # doesn\'t parse passes through unchanged (the server is the source of truth).\n if type_ is datetime or type_ is date:\n if not isinstance(data, str):\n return data\n try:\n # `datetime` accepts a bare date too; `date` rejects a timestamp, so trim it.\n return (\n datetime.fromisoformat(data)\n if type_ is datetime\n else date.fromisoformat(data[:10])\n )\n except ValueError:\n return data\n if dataclasses.is_dataclass(type_):\n hints = get_type_hints(type_)\n field_map = getattr(type_, "_field_map", {})\n kwargs = {}\n for field in dataclasses.fields(type_):\n wire = field_map.get(field.name, field.name)\n if isinstance(data, dict) and wire in data:\n kwargs[field.name] = decode(hints.get(field.name, Any), data[wire])\n return type_(**kwargs)\n return data\n\n\ndef encode(value: Any):\n """Python shape -> wire (JSON) shape; inverse of decode for request bodies."""\n if dataclasses.is_dataclass(value) and not isinstance(value, type):\n field_map = getattr(type(value), "_field_map", {})\n out = {}\n for field in dataclasses.fields(value):\n item = getattr(value, field.name)\n if item is None:\n continue\n out[field_map.get(field.name, field.name)] = encode(item)\n return out\n if isinstance(value, Enum):\n return value.value\n # A date-only value must not gain a time component on the way out.\n if isinstance(value, datetime):\n return value.isoformat()\n if isinstance(value, date):\n return value.isoformat()\n if isinstance(value, list):\n return [encode(item) for item in value]\n if isinstance(value, dict):\n return {key: encode(item) for key, item in value.items()}\n return value\n', + '# Reflective JSON <-> model conversion for generated Python clients. Models are\n# plain dataclasses by default, or pydantic BaseModels under `models: pydantic`;\n# one decoder serves both. For a dataclass it hydrates parsed JSON reflectively,\n# honoring each class\'s `_field_map` (python name -> wire name) and the typing\n# constructs the generator emits: Optional/Union, List, Dict, Enum, Literal, Any.\n# For a pydantic model it defers to pydantic, which already knows the aliases.\n# encode() mirrors whichever it was given back to wire shape.\nfrom __future__ import annotations\n\nimport dataclasses\nimport typing\nfrom datetime import date, datetime\nfrom enum import Enum\nfrom typing import Any, Dict, Tuple, get_args, get_origin, get_type_hints\n\n# Discriminated unions: resolved Union annotation -> (wire property, {value: class}).\n# The generated module registers its unions here; decode() dispatches through it\n# before falling back to trying members in order.\nDISCRIMINATORS: Dict[Any, Tuple[str, Dict[str, Any]]] = {}\n\n\ndef decode(type_: Any, data: Any):\n """Best-effort hydration: wire data -> the annotated Python shape. Unknown or\n mismatched shapes pass through unchanged (the server is the source of truth)."""\n if data is None or type_ is Any or type_ is None:\n return data\n origin = get_origin(type_)\n if origin is typing.Union:\n discriminator = DISCRIMINATORS.get(type_)\n if discriminator is not None and isinstance(data, dict):\n wire_property, mapping = discriminator\n target = mapping.get(data.get(wire_property))\n if target is not None:\n try:\n return decode(target, data)\n except (TypeError, ValueError, KeyError):\n pass\n for member in get_args(type_):\n if member is type(None):\n continue\n try:\n return decode(member, data)\n except (TypeError, ValueError, KeyError):\n continue\n return data\n if origin is list:\n (item_type,) = get_args(type_) or (Any,)\n return [decode(item_type, item) for item in data]\n if origin is dict:\n args = get_args(type_)\n value_type = args[1] if len(args) == 2 else Any\n return {key: decode(value_type, value) for key, value in data.items()}\n if origin is typing.Literal:\n return data\n if isinstance(type_, type) and issubclass(type_, Enum):\n return type_(data)\n # `dateType: Date` annotates date/date-time fields as datetime objects; a value that\n # doesn\'t parse passes through unchanged (the server is the source of truth).\n if type_ is datetime or type_ is date:\n if not isinstance(data, str):\n return data\n try:\n # `datetime` accepts a bare date too; `date` rejects a timestamp, so trim it.\n return (\n datetime.fromisoformat(data)\n if type_ is datetime\n else date.fromisoformat(data[:10])\n )\n except ValueError:\n return data\n # A pydantic model validates itself, aliases included. `ValidationError`\n # subclasses `ValueError`, so union member probing above still works.\n if isinstance(type_, type) and hasattr(type_, "model_validate"):\n return type_.model_validate(data)\n if dataclasses.is_dataclass(type_):\n hints = get_type_hints(type_)\n field_map = getattr(type_, "_field_map", {})\n kwargs = {}\n for field in dataclasses.fields(type_):\n wire = field_map.get(field.name, field.name)\n if isinstance(data, dict) and wire in data:\n kwargs[field.name] = decode(hints.get(field.name, Any), data[wire])\n return type_(**kwargs)\n return data\n\n\ndef encode(value: Any):\n """Python shape -> wire (JSON) shape; inverse of decode for request bodies."""\n # `mode="json"` resolves datetimes and enums the same way the branches below do,\n # and `exclude_none` matches the dataclass path: an unset optional is not sent.\n if hasattr(value, "model_dump") and not isinstance(value, type):\n return value.model_dump(by_alias=True, exclude_none=True, mode="json")\n if dataclasses.is_dataclass(value) and not isinstance(value, type):\n field_map = getattr(type(value), "_field_map", {})\n out = {}\n for field in dataclasses.fields(value):\n item = getattr(value, field.name)\n if item is None:\n continue\n out[field_map.get(field.name, field.name)] = encode(item)\n return out\n if isinstance(value, Enum):\n return value.value\n # A date-only value must not gain a time component on the way out.\n if isinstance(value, datetime):\n return value.isoformat()\n if isinstance(value, date):\n return value.isoformat()\n if isinstance(value, list):\n return [encode(item) for item in value]\n if isinstance(value, dict):\n return {key: encode(item) for key, item in value.items()}\n return value\n', '_send.py': '# The request core for generated Python clients — mirror of the TypeScript\n# runtime\'s send.ts: default + config + per-call headers, on_request middleware\n# BEFORE serialization (mutations are sent), the retry loop (idempotent-methods\n# default, Idempotency-Key opt-in makes POST/PATCH safe, Retry-After honored,\n# exponential backoff with full jitter, a fresh timeout budget per attempt), and\n# the reverse on_response onion.\nfrom __future__ import annotations\n\nimport asyncio\nimport random\nimport time\nimport uuid\nfrom dataclasses import dataclass\nfrom typing import Any, Dict, Generic, List, Optional, Tuple, TypeVar\n\nimport httpx\n\nfrom ._errors import ApiTimeoutError\n\nT = TypeVar("T")\n\n\n@dataclass\nclass Envelope(Generic[T]):\n """A *_with_headers() result: decoded body + coerced declared headers + raw response."""\n\n data: T\n headers: Dict[str, Any]\n response: httpx.Response\n\n\ndef read_envelope_headers(\n response: httpx.Response, specs: List[Tuple[str, str, str]]\n) -> Dict[str, Any]:\n """Coerce declared response headers per (name, key, type) specs; absent/unparsable omitted."""\n headers: Dict[str, Any] = {}\n for name, key, type_ in specs:\n raw = response.headers.get(name)\n if raw is None:\n continue\n if type_ in ("integer", "number"):\n try:\n headers[key] = int(raw) if type_ == "integer" else float(raw)\n except ValueError:\n pass\n elif type_ == "boolean":\n lower = raw.strip().lower()\n if lower in ("true", "false"):\n headers[key] = lower == "true"\n else:\n headers[key] = raw\n return headers\n\n\n_IDEMPOTENT_METHODS = {"GET", "HEAD", "PUT", "DELETE", "OPTIONS"}\n_TRANSIENT_STATUS = {408, 429, 500, 502, 503, 504}\n\n\ndef _default_retry_on(method: str, headers: Dict[str, str], response: Optional[httpx.Response]) -> bool:\n safe = method.upper() in _IDEMPOTENT_METHODS or "Idempotency-Key" in headers\n if not safe:\n return False\n return response is None or response.status_code in _TRANSIENT_STATUS\n\n\ndef _retry_delay(retry: Dict[str, Any], attempt: int, retry_after: Optional[str]) -> float:\n if retry_after:\n try:\n return float(retry_after)\n except ValueError:\n pass # HTTP-date form: fall through to backoff\n base = float(retry.get("retry_delay", 1.0))\n raw = base if retry.get("retry_strategy") == "fixed" else base * (2 ** (attempt - 1))\n return random.uniform(0, raw) if retry.get("jitter", True) is not False else raw\n\n\ndef send(\n client: httpx.Client,\n config: Dict[str, Any],\n op: Dict[str, Any],\n url: str,\n *,\n method: str,\n headers: Optional[Dict[str, str]] = None,\n params: Optional[Dict[str, Any]] = None,\n json_body: Any = None,\n content: Any = None,\n data: Any = None,\n files: Any = None,\n timeout: Optional[float] = None,\n idempotency_key: Any = None,\n retry: Optional[Dict[str, Any]] = None,\n) -> httpx.Response:\n merged_retry: Dict[str, Any] = {**(config.get("retry") or {}), **(retry or {})}\n effective_timeout = timeout if timeout is not None else config.get("timeout")\n merged_headers: Dict[str, str] = {**(config.get("headers") or {}), **(headers or {})}\n\n # One stable key per LOGICAL call — set before the retry loop so every\n # attempt re-sends the same key; a caller-provided header always wins.\n key = idempotency_key if idempotency_key is not None else config.get("idempotency_key")\n if (\n key not in (None, False)\n and method.upper() in ("POST", "PATCH")\n and "Idempotency-Key" not in merged_headers\n ):\n merged_headers["Idempotency-Key"] = (\n key if isinstance(key, str) else key() if callable(key) else str(uuid.uuid4())\n )\n\n context = {\n "url": url,\n "method": method.upper(),\n "headers": merged_headers,\n "body": json_body,\n "operation": op,\n }\n middleware: List[Any] = config.get("middleware") or []\n for mw in middleware:\n on_request = getattr(mw, "on_request", None) or (mw.get("on_request") if isinstance(mw, dict) else None)\n if on_request:\n on_request(context)\n\n max_attempts = 1 + int(merged_retry.get("retries", 0))\n retry_on = merged_retry.get("retry_on") or (\n lambda ctx: _default_retry_on(context["method"], context["headers"], ctx.get("response"))\n )\n\n attempt = 0\n while True:\n attempt += 1\n try:\n response = client.request(\n context["method"],\n context["url"],\n headers=context["headers"],\n params=params,\n json=context["body"] if content is None and files is None and data is None else None,\n content=content,\n data=data,\n files=files,\n timeout=effective_timeout if effective_timeout is not None else httpx.USE_CLIENT_DEFAULT,\n )\n except httpx.TimeoutException:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n time.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise ApiTimeoutError(op.get("id", "?"), float(effective_timeout or 0), attempt) from None\n except httpx.TransportError:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n time.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise\n\n for mw in reversed(middleware):\n on_response = getattr(mw, "on_response", None) or (mw.get("on_response") if isinstance(mw, dict) else None)\n if on_response:\n replaced = on_response(response, context)\n if replaced is not None:\n response = replaced\n\n if (\n not response.is_success\n and attempt < max_attempts\n and retry_on({"attempt": attempt, "response": response})\n ):\n time.sleep(_retry_delay(merged_retry, attempt, response.headers.get("retry-after")))\n continue\n return response\n\n\nasync def send_async(\n client: httpx.AsyncClient,\n config: Dict[str, Any],\n op: Dict[str, Any],\n url: str,\n *,\n method: str,\n headers: Optional[Dict[str, str]] = None,\n params: Optional[Dict[str, Any]] = None,\n json_body: Any = None,\n content: Any = None,\n data: Any = None,\n files: Any = None,\n timeout: Optional[float] = None,\n idempotency_key: Any = None,\n retry: Optional[Dict[str, Any]] = None,\n) -> httpx.Response:\n """The async mirror of send() — same retry/timeout/idempotency semantics."""\n merged_retry: Dict[str, Any] = {**(config.get("retry") or {}), **(retry or {})}\n effective_timeout = timeout if timeout is not None else config.get("timeout")\n merged_headers: Dict[str, str] = {**(config.get("headers") or {}), **(headers or {})}\n key = idempotency_key if idempotency_key is not None else config.get("idempotency_key")\n if (\n key not in (None, False)\n and method.upper() in ("POST", "PATCH")\n and "Idempotency-Key" not in merged_headers\n ):\n merged_headers["Idempotency-Key"] = (\n key if isinstance(key, str) else key() if callable(key) else str(uuid.uuid4())\n )\n context = {\n "url": url,\n "method": method.upper(),\n "headers": merged_headers,\n "body": json_body,\n "operation": op,\n }\n middleware: List[Any] = config.get("middleware") or []\n for mw in middleware:\n on_request = getattr(mw, "on_request", None) or (mw.get("on_request") if isinstance(mw, dict) else None)\n if on_request:\n on_request(context)\n max_attempts = 1 + int(merged_retry.get("retries", 0))\n retry_on = merged_retry.get("retry_on") or (\n lambda ctx: _default_retry_on(context["method"], context["headers"], ctx.get("response"))\n )\n attempt = 0\n while True:\n attempt += 1\n try:\n response = await client.request(\n context["method"],\n context["url"],\n headers=context["headers"],\n params=params,\n json=context["body"] if content is None and files is None and data is None else None,\n content=content,\n data=data,\n files=files,\n timeout=effective_timeout if effective_timeout is not None else httpx.USE_CLIENT_DEFAULT,\n )\n except httpx.TimeoutException:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n await asyncio.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise ApiTimeoutError(op.get("id", "?"), float(effective_timeout or 0), attempt) from None\n except httpx.TransportError:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n await asyncio.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise\n for mw in reversed(middleware):\n on_response = getattr(mw, "on_response", None) or (mw.get("on_response") if isinstance(mw, dict) else None)\n if on_response:\n replaced = on_response(response, context)\n if replaced is not None:\n response = replaced\n if (\n not response.is_success\n and attempt < max_attempts\n and retry_on({"attempt": attempt, "response": response})\n ):\n await asyncio.sleep(_retry_delay(merged_retry, attempt, response.headers.get("retry-after")))\n continue\n return response\n', '_paginate.py': diff --git a/packages/client-generator/src/generators/__tests__/python.test.ts b/packages/client-generator/src/generators/__tests__/python.test.ts index 7d42712cd7..365b4f5eb8 100644 --- a/packages/client-generator/src/generators/__tests__/python.test.ts +++ b/packages/client-generator/src/generators/__tests__/python.test.ts @@ -61,6 +61,33 @@ describe('renderPythonModels', () => { expect(note).toBeGreaterThan(id); }); + it("renders pydantic models under models: 'pydantic', with wire names as aliases", () => { + const out = renderPythonModels( + model({ + Order: { + kind: 'object', + properties: [ + { name: 'id', schema: STRING, required: true }, + // A wire name that is not a legal Python field name: the alias carries it. + { name: 'class', schema: STRING, required: false }, + ], + }, + }), + 'string', + 'pydantic' + ); + expect(out).toContain('from pydantic import BaseModel, ConfigDict, Field'); + expect(out).toContain('class Order(BaseModel):'); + expect(out).toContain('model_config = ConfigDict(populate_by_name=True)'); + expect(out).toContain('id: str'); + expect(out).toContain('class_: Optional[str] = Field(default=None, alias="class")'); + // The alias replaces `_field_map`, and `ClassVar` typed only that map. + expect(out).not.toContain('@dataclass'); + expect(out).not.toContain('_field_map'); + expect(out).not.toContain('ClassVar'); + expect(out).not.toContain('from dataclasses import'); + }); + it('flattens allOf compositions into one dataclass', () => { const out = renderPythonModels( model({ diff --git a/packages/client-generator/src/generators/meta.ts b/packages/client-generator/src/generators/meta.ts index 5e318b270f..f706b3c0e5 100644 --- a/packages/client-generator/src/generators/meta.ts +++ b/packages/client-generator/src/generators/meta.ts @@ -96,6 +96,7 @@ export const BUILTIN_META: Record = { run: m.pythonGenerator, sample: m.pythonSample, docs: m.pythonDocs, + options: m.pythonOptions, })), }, // go emits a standalone full Go SDK (stdlib-only) — no TypeScript involved. diff --git a/packages/client-generator/src/generators/python/AGENTS.md b/packages/client-generator/src/generators/python/AGENTS.md index 12600b16e3..2aa1e54ec4 100644 --- a/packages/client-generator/src/generators/python/AGENTS.md +++ b/packages/client-generator/src/generators/python/AGENTS.md @@ -22,9 +22,24 @@ One self-contained `.py`: typed dataclass models, a sync `Client` and an a `identifierFor(stem, snake)`, so `rebilly-core.client.ts` emits `rebilly_core_client.py` and `import rebilly_core_client` just works. -- **Models are dataclasses**, required fields first (a dataclass constraint), optionals - `Optional[T] = None`. Wire names live in a `_field_map: ClassVar[Dict[str, str]]`; +- **Models are dataclasses by default**, required fields first (a dataclass constraint), + optionals `Optional[T] = None`. Wire names live in a `_field_map: ClassVar[Dict[str, str]]`; decode/encode is reflective (`_decode.py`, `get_type_hints`) — no per-model codecs. +- **`models: pydantic` emits `BaseModel` classes instead**, for the FastAPI-shaped half of + the ecosystem that expects them. A wire name becomes `Field(alias=…)` with + `populate_by_name=True`, so `_field_map` is not emitted in this mode — the alias is the + mapping. Everything else is unchanged: the same class names, the same field names, the + same `Optional[T] = None`, the same enums and union aliases, the same client and runtime. + Switching modes must not change a call site. +- **One runtime serves both model modes.** `_decode.py` dispatches on the target: a class + with `model_validate` is validated by pydantic, a dataclass is hydrated reflectively, and + `encode` mirrors that with `model_dump(by_alias=True, exclude_none=True, mode="json")`. + A second runtime variant per mode would double the surface that has to stay in step, and + pydantic's `ValidationError` already subclasses `ValueError`, so union member probing + needs no new except clause. +- **`models: pydantic` adds a dependency, and the header says so.** The default mode keeps + httpx as the only requirement; the pydantic header asks for both. A mode that quietly + needed a package the file never named would fail at import with nothing to act on. - **Naming:** fields/methods snake*case via `identifierFor(..., RESERVED_WORDS.python)`; reserved words get a trailing underscore (`class*`); `+1`/`-1`become`plus_1`/`minus_1`. - **Enums** are `class X(str, Enum)` with SCREAMING members; **unions** are `Union[...]` diff --git a/packages/client-generator/src/generators/python/index.ts b/packages/client-generator/src/generators/python/index.ts index 5883f0e817..96d0a83d0c 100644 --- a/packages/client-generator/src/generators/python/index.ts +++ b/packages/client-generator/src/generators/python/index.ts @@ -27,7 +27,7 @@ import type { SchemaModel, ServerModel, } from '../../intermediate-representation/model.js'; -import type { CodeSample, Generator, SampleContext } from '../types.js'; +import type { CodeSample, Generator, GeneratorOptionsSchema, SampleContext } from '../types.js'; const PY = RESERVED_WORDS.python; @@ -94,16 +94,41 @@ function writeDocstring(printer: Printer, description?: string): void { printer.line('"""'); } +/** The model style the generator emits: plain dataclasses, or pydantic `BaseModel`s. */ +export type PythonModels = 'dataclass' | 'pydantic'; + +export const pythonOptions: GeneratorOptionsSchema = { + type: 'object', + properties: { + models: { + enum: ['dataclass', 'pydantic'], + default: 'dataclass', + description: + 'Model style: standard-library dataclasses (default, httpx is the only dependency), or pydantic BaseModel classes (adds pydantic).', + }, + }, + additionalProperties: false, +}; + function writeDataclass( printer: Printer, name: string, properties: PropertyModel[], dateType: DateType, + models: PythonModels, description?: string ): void { - printer.line('@dataclass'); - printer.block(`class ${className(name)}:`, () => { + const pydantic = models === 'pydantic'; + if (!pydantic) printer.line('@dataclass'); + const header = pydantic ? `class ${className(name)}(BaseModel):` : `class ${className(name)}:`; + printer.block(header, () => { writeDocstring(printer, description); + // A wire name that is not a legal field name travels as an alias, so the model + // accepts both spellings; without this, populating by field name would fail. + if (pydantic) { + printer.line('model_config = ConfigDict(populate_by_name=True)'); + printer.blank(); + } // Required fields first — a dataclass field without a default may not follow one with. const ordered = [ ...properties.filter((property) => property.required), @@ -113,13 +138,16 @@ function writeDataclass( if (ordered.length === 0) printer.line('pass'); for (const property of ordered) { const { python, renamed } = fieldName(property.name); - if (renamed) fieldMap.push([python, property.name]); + if (renamed && !pydantic) fieldMap.push([python, property.name]); + const alias = renamed && pydantic ? `alias=${JSON.stringify(property.name)}` : undefined; const baseType = pythonType(property.schema, dateType); if (property.required) { - printer.line(`${python}: ${baseType}`); + const value = alias === undefined ? '' : ` = Field(${alias})`; + printer.line(`${python}: ${baseType}${value}`); } else { const optional = baseType.startsWith('Optional[') ? baseType : `Optional[${baseType}]`; - printer.line(`${python}: ${optional} = None`); + const value = alias === undefined ? 'None' : `Field(default=None, ${alias})`; + printer.line(`${python}: ${optional} = ${value}`); } } if (fieldMap.length > 0) { @@ -134,15 +162,32 @@ function writeDataclass( } /** Render every named schema: Enum classes, dataclasses (allOf flattened), union aliases. */ -export function renderPythonModels(model: ApiModel, dateType: DateType = 'string'): string { +export function renderPythonModels( + model: ApiModel, + dateType: DateType = 'string', + models: PythonModels = 'dataclass' +): string { const printer = new Printer(' '); printer.line('from __future__ import annotations'); printer.blank(); - printer.line('from dataclasses import dataclass'); + if (models === 'dataclass') printer.line('from dataclasses import dataclass'); printer.line('from enum import Enum'); - printer.line( - 'from typing import Any, AsyncIterator, ClassVar, Dict, Iterator, List, Literal, Optional, Tuple, Union' - ); + // `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'); + 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(); @@ -171,6 +216,7 @@ export function renderPythonModels(model: ApiModel, dateType: DateType = 'string name, flat.properties, dateType, + models, flat.description ?? schema.description ); continue; @@ -666,19 +712,24 @@ function pythonModulePath(outputPath: string): string { } /** The whole generated file: header, models, embedded runtime, descriptors, clients. */ -export const pythonGenerator: Generator = ({ model, outputPath, emit }) => { +export const pythonGenerator: Generator = ({ model, outputPath, emit, options }) => { const errorMode = emit.errorMode ?? 'throw'; const dateType = emit.dateType ?? 'string'; + const models = (options?.models as PythonModels | undefined) ?? 'dataclass'; const printer = new Printer(' '); printer.line( `# Generated by @redocly/client-generator (python) from "${model.title}" ${model.version}.` ); printer.line('# Do not edit by hand — regenerate with `redocly generate-client`.'); - printer.line('# Requires Python >= 3.9 and httpx: pip install httpx'); + printer.line( + models === 'pydantic' + ? '# Requires Python >= 3.9, httpx, and pydantic: pip install httpx pydantic' + : '# Requires Python >= 3.9 and httpx: pip install httpx' + ); printer.blank(); // Models (with the shared imports header). - printer.line(renderPythonModels(model, dateType).trimEnd()); + printer.line(renderPythonModels(model, dateType, models).trimEnd()); printer.blank(); printer.blank(); writePythonServers(printer, model); diff --git a/tests/e2e/generate-client/python.test.ts b/tests/e2e/generate-client/python.test.ts index cafb97f395..3faa43c4a5 100644 --- a/tests/e2e/generate-client/python.test.ts +++ b/tests/e2e/generate-client/python.test.ts @@ -1,9 +1,10 @@ import { spawnSync, type ChildProcess } from 'node:child_process'; -import { existsSync, rmSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { generate, killServer, startServer } from './helpers.js'; +import { cliEntry, generate, killServer, startServer } from './helpers.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const fixture = join(__dirname, 'fixtures/base.yaml'); @@ -15,6 +16,7 @@ const SERVER_BASE = `http://127.0.0.1:${SERVER_PORT}`; const hasPython = spawnSync('python3', ['--version']).status === 0; const hasHttpx = hasPython && spawnSync('python3', ['-c', 'import httpx']).status === 0; +const hasPydantic = hasPython && spawnSync('python3', ['-c', 'import pydantic']).status === 0; describe('generate-client python generator (end-to-end)', () => { afterAll(() => { @@ -60,3 +62,81 @@ describe('generate-client python generator (end-to-end)', () => { 60_000 ); }); + +describe('generate-client python generator, models: pydantic (end-to-end)', () => { + // `models` is config-only, like every per-generator option, so this drives a config file. + let dir: string; + let generated: string; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'python-pydantic-')); + writeFileSync( + join(dir, 'redocly.yaml'), + [ + 'apis:', + ' cafe:', + ` root: ${join(__dirname, 'fixtures/cafe.yaml')}`, + ' clientOutput: ./client.ts', + ' client:', + ' generators: [python]', + ' options:', + ' python:', + ' models: pydantic', + ].join('\n'), + 'utf-8' + ); + const result = spawnSync('node', [cliEntry, 'generate-client'], { + cwd: dir, + encoding: 'utf-8', + }); + expect(result.status, result.stderr).toBe(0); + generated = join(dir, 'client.py'); + }); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('emits BaseModel classes and names pydantic in the header', () => { + const source = readFileSync(generated, 'utf-8'); + expect(source).toContain('pip install httpx pydantic'); + expect(source).toContain('from pydantic import BaseModel, ConfigDict, Field'); + expect(source).toContain('(BaseModel):'); + // The client and the runtime are the same in both model modes. + expect(source).toContain('class Client:'); + expect(source).toContain('def decode('); + }); + + it.skipIf(!hasPython)('the generated client is valid Python', () => { + const result = spawnSync('python3', ['-m', 'py_compile', generated], { encoding: 'utf-8' }); + expect(result.status, result.stderr).toBe(0); + }); + + it.skipIf(!hasPydantic)('decodes wire names through aliases and encodes them back', () => { + // One round trip proves the three pieces of this mode: the alias, the runtime + // dispatch to pydantic, and `by_alias` on the way out. + const script = [ + 'import json, sys', + `sys.path.insert(0, ${JSON.stringify(dir)})`, + 'import client', + 'wire = {"customerName": "Sam", "orderItems": [], "id": "ord_1", "totalPrice": 900}', + 'order = client.decode(client.Order, wire)', + 'assert type(order).__name__ == "Order", type(order)', + // The wire name arrives on the aliased field, and leaves on the alias again. + 'assert order.customer_name == "Sam", order', + 'assert order.total_price == 900, order', + 'assert client.encode(order) == wire, client.encode(order)', + // A required field missing must fail loudly: that is what this mode buys. + 'import pydantic', + 'try:', + ' client.decode(client.Order, {"id": "ord_1"})', + ' raise AssertionError("expected a validation error")', + 'except pydantic.ValidationError:', + ' pass', + 'print("PYDANTIC_ROUND_TRIP_OK")', + ].join('\n'); + const result = spawnSync('python3', ['-c', script], { encoding: 'utf-8' }); + expect(result.status, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`).toBe(0); + expect(result.stdout).toContain('PYDANTIC_ROUND_TRIP_OK'); + }); +}); From daefd833dd0a811927b47857344c796e4aab1105 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 18 Aug 2026 16:33:03 +0300 Subject: [PATCH 195/211] docs: add generator recipes, with a runnable valibot example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "You support a validation library I don't use" has one honest answer: write the generator, it is short. So the custom-generator guide gains a Recipes section — a schema library the built-ins do not cover, a framework wrapper, a shape your codebase already uses, and changing a built-in through eject rather than starting from a blank file. The first recipe now points at a runnable example instead of prose. The examples suite discovers it and generates it, and `typecheck:examples` checks it against real valibot, so the recipe cannot rot. Writing it also proved its own worth: the first version mapped every string to `v.string()`, and `tsc` rejected it, because a `format: binary` property is a `Blob` in the client. Reading `metadata.format` is now both the fix and a bullet in the example's README. --- .../@v2/guides/customize-client-generation.md | 27 +++++++ package-lock.json | 16 ++++ package.json | 1 + tests/e2e/generate-client/examples/README.md | 1 + .../examples/valibot-generator/.gitignore | 3 + .../examples/valibot-generator/README.md | 45 +++++++++++ .../examples/valibot-generator/package.json | 14 ++++ .../examples/valibot-generator/redocly.yaml | 10 +++ .../examples/valibot-generator/src/main.ts | 17 +++++ .../examples/valibot-generator/tsconfig.json | 4 + .../valibot-schema-generator.mjs | 75 +++++++++++++++++++ 11 files changed, 213 insertions(+) create mode 100644 tests/e2e/generate-client/examples/valibot-generator/.gitignore create mode 100644 tests/e2e/generate-client/examples/valibot-generator/README.md create mode 100644 tests/e2e/generate-client/examples/valibot-generator/package.json create mode 100644 tests/e2e/generate-client/examples/valibot-generator/redocly.yaml create mode 100644 tests/e2e/generate-client/examples/valibot-generator/src/main.ts create mode 100644 tests/e2e/generate-client/examples/valibot-generator/tsconfig.json create mode 100644 tests/e2e/generate-client/examples/valibot-generator/valibot-schema-generator.mjs diff --git a/docs/@v2/guides/customize-client-generation.md b/docs/@v2/guides/customize-client-generation.md index a700a9420a..694cb70932 100644 --- a/docs/@v2/guides/customize-client-generation.md +++ b/docs/@v2/guides/customize-client-generation.md @@ -324,11 +324,38 @@ export default defineGenerator({ Write your own page instead if the standard layout does not fit: the hook returns files, so the content is yours. An ejected generator keeps its `docs` hook, so the page layout is ejectable with the generator that owns it. +### Recipes + +The built-in generators cover the common targets, and a custom generator covers the rest. +These are the shapes people ask for most often, each a file you copy rather than a product to wait for. + +**A schema library the built-ins do not cover.** +The built-in `zod` generator emits Zod schemas. +For another library, walk `model.schemas` and print the expression that library expects. +`flattenAllOf` merges `allOf` compositions into one property list, `enumValues` returns the values of an enum, and `metadata.format` tells you when a string is really binary content. +The [`valibot-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/valibot-generator) does this in about 60 lines, and it type-checks against the real library in our CI. + +**A framework wrapper.** +The built-in `tanstack-query` and `swr` generators forward to the client's operation functions. +A wrapper for another framework is the same job: read the operations, emit one function or hook per operation, and forward to the generated call. +Declare `requires: ['typescript']` so the client it wraps is always there, and `errorModes: ['throw']` if the wrapper expects a thrown error. + +**A shape your codebase already uses.** +A resource facade, a permissions matrix, a route map, a fixtures file. +The [`nested-facade`](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/nested-facade) and [`custom-generator`](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/custom-generator) examples are two of these. + +**A change to a built-in generator, not a new one.** +Start from its code instead of a blank file: `redocly eject-generator ` writes the built-in into your repository, with its design as an agent skill, and an unmodified copy produces byte-identical output. +This is the shorter path whenever your requirement is "the built-in output, but different". + +Every one of these runs in the same pass as the built-ins, reads the same API model, and adds no dependency to the generated client. + Import-specifier generators execute at generation time. They have the same trust level as any installed dependency that you run. ## Resources +- **[`valibot-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/valibot-generator)** - Copy a ~60-line generator that emits schemas for a validation library the built-ins do not cover - **[`typescript-types-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/typescript-types-generator)** - Learn how to use the runnable plugin based on `tsType` and how to type-import referenced schemas - **[`custom-generator` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/custom-generator)** - An example of minimal generator that builds strings - **[`nested-facade` example](https://github.com/Redocly/redocly-cli/tree/main/tests/e2e/generate-client/examples/nested-facade)** - An example of a realistic generator that derives an `api..` facade from the description's tags. diff --git a/package-lock.json b/package-lock.json index 6b4168f7fc..81fd44581a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39,6 +39,7 @@ "tsx": "^4.19.3", "typescript": "6.0.2", "typescript7": "npm:typescript@7.0.2", + "valibot": "^1.4.2", "vitest": "^4.1.8", "zod": "^4.0.0" }, @@ -10534,6 +10535,21 @@ "dev": true, "license": "MIT" }, + "node_modules/valibot": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.4.2.tgz", + "integrity": "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/vfile": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/vfile/-/vfile-4.2.1.tgz", diff --git a/package.json b/package.json index 34cbe426fa..eb6aede0b9 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "tsx": "^4.19.3", "typescript": "6.0.2", "typescript7": "npm:typescript@7.0.2", + "valibot": "^1.4.2", "vitest": "^4.1.8", "zod": "^4.0.0" }, diff --git a/tests/e2e/generate-client/examples/README.md b/tests/e2e/generate-client/examples/README.md index 4eef5b171a..f2678424a7 100644 --- a/tests/e2e/generate-client/examples/README.md +++ b/tests/e2e/generate-client/examples/README.md @@ -26,6 +26,7 @@ The generated client under `src/api/` is gitignored — CI regenerates every cli | [custom-pagination](./custom-pagination) | CLI · `typescript` | hand-written paging over the typed client for shapes the built-in styles don't cover (body cursors) | | [custom-generator](./custom-generator) | CLI · `typescript` + custom generator | a local `generators` plugin emitting a `: 'METHOD /path'` route map next to the client | | [typescript-types-generator](./typescript-types-generator) | CLI · `typescript` + custom generator | a plugin rendering real TypeScript types via `@redocly/client-generator/generate` (`tsType`) — a typed response-shape map | +| [valibot-generator](./valibot-generator) | CLI · `typescript` + custom generator | a ~60-line custom generator emitting Valibot schemas — the recipe for a validation library the built-ins do not cover | | [nested-facade](./nested-facade) | CLI · `typescript` + custom generator | `api..` facade derived from the spec's tags by a plugin — regenerates with the spec | | [cli](./cli) | CLI · `typescript`, `zod`, `cli` · `docs` | a bin-ready command-line interface over the client: typed flags, `--json` bodies, `--dry-run`, a documented exit-code contract | | [python-sdk](./python-sdk) | CLI · `python` · `docs` | a full Python SDK (httpx): typed dataclasses, sync/async clients, pagination iterators | diff --git a/tests/e2e/generate-client/examples/valibot-generator/.gitignore b/tests/e2e/generate-client/examples/valibot-generator/.gitignore new file mode 100644 index 0000000000..612acc5cae --- /dev/null +++ b/tests/e2e/generate-client/examples/valibot-generator/.gitignore @@ -0,0 +1,3 @@ +node_modules +src/api/ +package-lock.json diff --git a/tests/e2e/generate-client/examples/valibot-generator/README.md b/tests/e2e/generate-client/examples/valibot-generator/README.md new file mode 100644 index 0000000000..4f330fac62 --- /dev/null +++ b/tests/e2e/generate-client/examples/valibot-generator/README.md @@ -0,0 +1,45 @@ +# valibot-generator + +A custom generator that emits [Valibot](https://valibot.dev) schemas beside the client, in about 60 lines. + +The built-in validation generator emits [Zod](https://zod.dev) schemas. +This example exists to show what to do when the built-ins do not cover the library you use: you write the generator, and it runs in the same pass as the built-in ones. + +```bash +npm run generate # redocly generate-client +``` + +That writes two files from one description: + +- `src/api/client.ts` — the typed client, from the built-in `typescript` generator. +- `src/api/client.valibot.ts` — one `Schema` per named schema, plus the inferred type, from [`valibot-schema-generator.mjs`](./valibot-schema-generator.mjs). + +`src/main.ts` uses both: the client types the call, and `v.parse` checks the value at run time. + +## What the generator shows + +- **The API model is the input.** `model.schemas` is the list of named schemas, each already resolved. +- **Composition is solved for you.** `flattenAllOf` merges an `allOf` chain into one property list, so the generator never implements composition semantics. +- **Enums come with their values.** `enumValues` returns them, and `v.picklist` takes them directly. +- **`Printer` builds the text.** No template language, and no whitespace bookkeeping. +- **Metadata carries `format`.** A `format: binary` property is a `Blob` in the client, so the schema uses `v.blob()`. + A generator that ignored `format` would emit a schema that disagrees with the client's own type, and this example's `tsc` bar would fail. + +Nothing here is privileged: the built-in `zod` generator has the same shape, and this file could be published as a package or committed in your repo. + +## Configuration + +The generator is selected by path, next to a built-in name: + +```yaml +apis: + valibot-generator: + root: ../_shared/cafe.yaml + clientOutput: ./src/api/client.ts + client: + generators: + - typescript + - ./valibot-schema-generator.mjs +``` + +See [Customize client generation](https://redocly.com/docs/cli/guides/customize-client-generation) for the full contract: declared options, the helper table, compatibility ranges, and ejecting a built-in generator to start from its code instead of a blank file. diff --git a/tests/e2e/generate-client/examples/valibot-generator/package.json b/tests/e2e/generate-client/examples/valibot-generator/package.json new file mode 100644 index 0000000000..0cf3dcb956 --- /dev/null +++ b/tests/e2e/generate-client/examples/valibot-generator/package.json @@ -0,0 +1,14 @@ +{ + "name": "@redocly-examples/valibot-generator", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "generate": "redocly generate-client" + }, + "devDependencies": { + "@redocly/cli": "latest", + "typescript": "^5.5.0", + "valibot": "^1.4.0" + } +} diff --git a/tests/e2e/generate-client/examples/valibot-generator/redocly.yaml b/tests/e2e/generate-client/examples/valibot-generator/redocly.yaml new file mode 100644 index 0000000000..ed2364b961 --- /dev/null +++ b/tests/e2e/generate-client/examples/valibot-generator/redocly.yaml @@ -0,0 +1,10 @@ +# redocly.yaml — drives `redocly generate-client` for this example. +# The custom generator is selected by path, beside the built-in `typescript`. +apis: + valibot-generator: + root: ../_shared/cafe.yaml + clientOutput: ./src/api/client.ts + client: + generators: + - typescript + - ./valibot-schema-generator.mjs diff --git a/tests/e2e/generate-client/examples/valibot-generator/src/main.ts b/tests/e2e/generate-client/examples/valibot-generator/src/main.ts new file mode 100644 index 0000000000..23673a9dcd --- /dev/null +++ b/tests/e2e/generate-client/examples/valibot-generator/src/main.ts @@ -0,0 +1,17 @@ +// The generated client, plus schemas from a validation library the built-ins do not cover. +// Both come from one description and one `redocly generate-client` run. +import * as v from 'valibot'; + +import { listMenuItems } from './api/client.js'; +import { MenuItemListSchema, type MenuItemList } from './api/client.valibot.js'; + +const menu: MenuItemList = await listMenuItems(); + +// The client already types this value from the description. The schema checks it at run +// time, which is what catches a server that has drifted from the description. +const checked = v.parse(MenuItemListSchema, menu); +console.log(`${checked.items?.length ?? 0} items`); + +// `v.safeParse` for the non-throwing shape, the same as any hand-written Valibot code. +const result = v.safeParse(MenuItemListSchema, { items: 'not an array' }); +if (!result.success) console.log(`rejected: ${result.issues[0].message}`); diff --git a/tests/e2e/generate-client/examples/valibot-generator/tsconfig.json b/tests/e2e/generate-client/examples/valibot-generator/tsconfig.json new file mode 100644 index 0000000000..4bd6962d40 --- /dev/null +++ b/tests/e2e/generate-client/examples/valibot-generator/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../tsconfig.base.json", + "include": ["src"] +} diff --git a/tests/e2e/generate-client/examples/valibot-generator/valibot-schema-generator.mjs b/tests/e2e/generate-client/examples/valibot-generator/valibot-schema-generator.mjs new file mode 100644 index 0000000000..14f2c9f6ae --- /dev/null +++ b/tests/e2e/generate-client/examples/valibot-generator/valibot-schema-generator.mjs @@ -0,0 +1,75 @@ +// A custom generator that emits Valibot schemas, one per named schema in the description. +// +// It exists to answer a question people ask about every code generator: "you support the +// validation library I don't use — now what?" The answer is this file. It is ~60 lines over +// the authoring toolkit, it ships no new dependency into the generated client, and nothing +// in it is privileged: the built-in `zod` generator is the same shape, only longer. +// +// Plain ESM so the CLI imports it under bare `node`. In TypeScript you would write: +// +// import { defineGenerator, flattenAllOf, enumValues } from '@redocly/client-generator'; +// export default defineGenerator({ name: 'valibot', run({ model, outputPath }) { … } }); +// +// `defineGenerator` only supplies types, so a plain object works the same. +import { enumValues, flattenAllOf, Printer } from '@redocly/client-generator'; + +/** A schema from the API model, as a Valibot expression. */ +function valibotSchema(schema, model) { + const asEnum = enumValues(schema); + if (asEnum !== undefined) { + return `v.picklist([${asEnum.values.map((value) => JSON.stringify(value)).join(', ')}])`; + } + switch (schema.kind) { + case 'scalar': + if (schema.scalar === 'integer' || schema.scalar === 'number') return 'v.number()'; + if (schema.scalar === 'boolean') return 'v.boolean()'; + // `format` rides on the schema metadata, so a generator can follow the same + // decisions the built-in generators make. `binary` is a `Blob` in the client, and a + // schema that called it a string would disagree with the type on every call site. + if (schema.metadata?.format === 'binary') return 'v.blob()'; + return 'v.string()'; + case 'literal': + return `v.literal(${JSON.stringify(schema.value)})`; + case 'array': + return `v.array(${valibotSchema(schema.items, model)})`; + case 'record': + return `v.record(v.string(), ${valibotSchema(schema.value, model)})`; + // A reference points at another emitted schema; `v.lazy` keeps a recursive one legal. + case 'ref': + return `v.lazy(() => ${schema.name}Schema)`; + case 'union': + return `v.union([${schema.members.map((member) => valibotSchema(member, model)).join(', ')}])`; + case 'null': + return 'v.null()'; + case 'object': + case 'intersection': { + // `flattenAllOf` merges an allOf composition into one property list, so this + // generator never implements composition semantics itself. + const flat = flattenAllOf(schema, model) ?? { properties: schema.properties ?? [] }; + const entries = flat.properties.map((property) => { + const inner = valibotSchema(property.schema, model); + const value = property.required ? inner : `v.optional(${inner})`; + return ` ${JSON.stringify(property.name)}: ${value},`; + }); + return entries.length === 0 ? 'v.object({})' : `v.object({\n${entries.join('\n')}\n})`; + } + default: + return 'v.unknown()'; + } +} + +export default { + name: 'valibot', + run({ model, outputPath }) { + const printer = new Printer(); + printer.line('// Generated by the valibot custom generator. Do not edit by hand.'); + printer.line("import * as v from 'valibot';"); + printer.blank(); + for (const { name, schema } of model.schemas) { + printer.line(`export const ${name}Schema = ${valibotSchema(schema, model)};`); + printer.line(`export type ${name} = v.InferOutput;`); + printer.blank(); + } + return [{ path: outputPath.replace(/\.ts$/, '.valibot.ts'), content: printer.toString() }]; + }, +}; From 15828092e7554a534c1418acee1f82a230ff4d48 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 18 Aug 2026 16:33:41 +0300 Subject: [PATCH 196/211] refactor(client-generator)!: drop the per-scheme credential setters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review asked why the generated client redeclares what the instance already has: `export const setBearer = client.auth.bearer;`. It is a fair question. Setting a credential had three spellings — the setter, `configure({ auth })`, and `client.auth.*` — for one act, which is the rule about one name per thing that this generator advertises about operation names. So the setters are gone, and two things go with them. `emitters/auth.ts` existed only to derive setter names, including the `setApiKey` versus `setApiKeyKeyA` disambiguation that several apiKey schemes forced; `client.auth.apiKey(key, value)` addresses a scheme by the key the description already gives it, so that problem does not exist. And the two identifier reservations those names fed are gone, so a description may now name an operation or a schema `setBearer` and keep the name — the former tests are turned around to assert exactly that. Callers move to `configure({ auth })` or `client.auth.*`: two e2e consumers, the configure-and-middleware example, and the guide's Authentication section, which now documents two ways instead of three. --- .changeset/agent-friendly-generators.md | 2 + docs/@v2/guides/use-generated-client.md | 56 +++++++++++----- .../client-assembly.test.ts.snap | 1 - .../src/emitters/__tests__/auth.test.ts | 66 ------------------- .../__tests__/client-assembly.test.ts | 36 +++------- .../src/emitters/__tests__/descriptor.test.ts | 16 +++-- .../client-generator/src/emitters/auth.ts | 33 ---------- .../src/emitters/client-assembly.ts | 26 ++------ .../src/emitters/descriptor.ts | 5 +- .../__tests__/sanitize-identifiers.test.ts | 9 +-- .../sanitize-identifiers.ts | 11 +--- tests/e2e/generate-client/auth.test.ts | 31 ++++----- .../generate-client/cafe-consumer/index.ts | 7 +- tests/e2e/generate-client/cafe.snapshot.ts | 2 - tests/e2e/generate-client/cafe.test.ts | 6 +- .../configure-and-middleware/README.md | 2 +- .../configure-and-middleware/src/main.ts | 6 +- .../package-runtime-consumer/index.ts | 4 +- tests/e2e/generate-client/split.test.ts | 2 +- 19 files changed, 102 insertions(+), 219 deletions(-) delete mode 100644 packages/client-generator/src/emitters/__tests__/auth.test.ts delete mode 100644 packages/client-generator/src/emitters/auth.ts diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index b428973f2f..ad92d92b7a 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -16,4 +16,6 @@ Added an `eject-generator` command that vendors any built-in generator, with its Renamed pagination operation extension from `x-redocly-pagination` to `x-redoclyPagination`. The previous name is no longer read. +**Note**: the generated TypeScript client no longer exports per-scheme credential setters (`setBearer`, `setBasicAuth`, `setApiKey`). Set credentials with `configure({ auth: … })` or on the instance with `client.auth.bearer(…)`, `client.auth.basic(…)`, and `client.auth.apiKey('', …)`. One consequence is welcome: a setter name is no longer reserved, so an operation or schema of that name keeps it. + **Note**: the TypeScript client generator is now selected as `typescript` instead of `sdk`, matching the language-named generators. Update `client.generators` lists and `--generator` flags; the old name fails with a message that points at the rename. diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index 8fb0981cc4..4d3ec3ec53 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -220,6 +220,22 @@ for order in client.list_orders_items(limit=50): print(order) ``` +The Python models are dataclasses, so `httpx` stays the only requirement. +If your project expects [pydantic](https://docs.pydantic.dev/) models, ask for them: + +```yaml +client: + generators: [python] + options: + python: + models: pydantic # default: dataclass +``` + +Every class then extends `BaseModel`, and a wire name that is not a legal Python field name becomes a field alias. +The call sites do not change: the same class names, the same field names, the same client. +Pydantic then validates each response as the SDK decodes it, so a response that does not match the description raises `ValidationError` instead of passing through. +This mode needs `pydantic` next to `httpx`, and the header of the generated file says so. + ```php require 'client.php'; @@ -246,8 +262,7 @@ for order, err := range api.ListOrdersItems(ctx, nil) { Every language gives credentials to a client instance, and the constructor is that one way. `createClient(OPERATIONS, { auth })` in TypeScript is the same thing as the constructors below. -TypeScript adds `setBearer` and `configure({ auth })` for one reason: it also exports a module-level client, which the [free functions](#authentication) call. -Those two configure that instance. +TypeScript adds `configure({ auth })` for one reason: it also exports a module-level client, which the [free functions](#authentication) call, and `configure` is how you set up that instance. The Python, PHP, and Go SDKs export no module-level client, so they need no equivalent. Auth accepts a static credential, or a provider function that the client resolves for each request: @@ -411,16 +426,28 @@ Credentials are **per instance**. They live in the client config (`ClientConfig.auth`). Each operation automatically sends the credentials that its `security` requires. A description that declares no `securitySchemes` produces a client with no auth code. -The generator emits a setter for each `securityScheme` that the runtime can apply: +Set credentials in one of two places, and both configure the same instance: -| Scheme | Setter | Applied as | -| ------------------------------ | ----------------------------------------- | ---------------------------------------- | -| HTTP `bearer` / OAuth2 | `setBearer(token)` | `Authorization: Bearer ` | -| HTTP `basic` | `setBasicAuth(user, pass)` | `Authorization: Basic ` | -| `apiKey` (header/query/cookie) | `setApiKey(key)` / `setApiKey(key)` | the named header, query param, or cookie | +```ts +import { client, configure } from './client.ts'; + +// Up front, with the rest of the configuration. +configure({ auth: { bearer: process.env.API_TOKEN } }); + +// Or one scheme at a time, by kind. +client.auth.bearer(process.env.API_TOKEN); +client.auth.basic({ username: 'svc', password: 's3cr3t' }); +client.auth.apiKey('SecretApiKey', process.env.API_KEY); // addressed by scheme key +``` + +| Scheme | How you set it | Applied as | +| ------------------------------ | ------------------------------------ | ---------------------------------------- | +| HTTP `bearer` / OAuth2 | `auth.bearer(token)` | `Authorization: Bearer ` | +| HTTP `basic` | `auth.basic({ username, password })` | `Authorization: Basic ` | +| `apiKey` (header/query/cookie) | `auth.apiKey('', value)` | the named header, query param, or cookie | -For a single apiKey scheme, the setter is `setApiKey` without a suffix. -For more than one scheme, each setter is `setApiKey`. +Each operation sends only the credentials its own `security` requires, so setting several is normal. +An apiKey scheme is addressed by the key the description gives it, so an API with several apiKey schemes needs no extra names. The runtime cannot inject `mutualTLS`. Cookie apiKey credentials travel in the `Cookie` request header, and browsers refuse to set this header. Because of this, cookie auth works only in server-side clients. @@ -429,15 +456,12 @@ Bearer and apiKey credentials accept a **`TokenProvider`**: a string, or a funct This is useful for refresh flows: ```ts -import { setBearer } from './client.ts'; +import { client } from './client.ts'; -setBearer(async () => await getFreshAccessToken()); +client.auth.bearer(async () => await getFreshAccessToken()); ``` -Each setter is shorthand for the `auth` member of the exported `client` instance (`export const setBearer = client.auth.bearer;`). -Because of this, the setter configures that instance. -As an alternative, pass credentials up front with `configure({ auth: { … } })`. -Or set them with `client.auth.bearer(…)`, `client.auth.basic(…)`, or `client.auth.apiKey(scheme, …)`. +The client resolves the provider for each request, so a refreshed token takes effect without reconfiguration. For **multiple independent instances** with different credentials, build extra clients from the same generated descriptors. The generated module exports `createClient`, the `OPERATIONS` descriptors, and the `Ops` type in both runtimes: diff --git a/packages/client-generator/src/emitters/__tests__/__snapshots__/client-assembly.test.ts.snap b/packages/client-generator/src/emitters/__tests__/__snapshots__/client-assembly.test.ts.snap index 420750eb85..264b103e04 100644 --- a/packages/client-generator/src/emitters/__tests__/__snapshots__/client-assembly.test.ts.snap +++ b/packages/client-generator/src/emitters/__tests__/__snapshots__/client-assembly.test.ts.snap @@ -71,7 +71,6 @@ export type OperationTag = Extract<(typeof OPERATIONS)[keyof typeof OPERATIONS], export const client = createClient(OPERATIONS, { serverUrl: "https://cafe.example.com", clientHeader: "redocly-client-generator" }); export const { configure, use } = client; -export const setBearer = client.auth.bearer; export const getOrder = (orderId: string, params: { expand?: string; } = {}, init?: I): Promise, I>> => client.getOrder({ orderId, params }, init) as Promise, I>>; diff --git a/packages/client-generator/src/emitters/__tests__/auth.test.ts b/packages/client-generator/src/emitters/__tests__/auth.test.ts deleted file mode 100644 index 8c89800339..0000000000 --- a/packages/client-generator/src/emitters/__tests__/auth.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { SecuritySchemeModel } from '../../intermediate-representation/model.js'; -import { apiKeySetterName, authSetterNames } from '../auth.js'; - -/** A spec exercising all five injectable kinds at once. */ -const allKinds: SecuritySchemeModel[] = [ - { kind: 'bearer', key: 'OAuth2' }, - { kind: 'basic', key: 'Basic' }, - { kind: 'apiKeyHeader', key: 'HeaderKey', headerName: 'X-API-Key' }, - { kind: 'apiKeyQuery', key: 'QueryKey', paramName: 'api_key' }, - { kind: 'apiKeyCookie', key: 'CookieKey', cookieName: 'sid' }, -]; - -describe('apiKeySetterName', () => { - it('is the bare setApiKey for a sole apiKey scheme', () => { - expect(apiKeySetterName('anything', true)).toBe('setApiKey'); - }); - - it('suffixes the PascalCased scheme key when several apiKey schemes exist', () => { - expect(apiKeySetterName('cookieAuth', false)).toBe('setApiKeyCookieAuth'); - expect(apiKeySetterName('QueryKey', false)).toBe('setApiKeyQueryKey'); - }); -}); - -describe('authSetterNames', () => { - it('returns no names when there are no schemes', () => { - expect(authSetterNames([])).toEqual([]); - }); - - it('emits setBearer once for any number of bearer schemes', () => { - expect( - authSetterNames([ - { kind: 'bearer', key: 'OAuth2' }, - { kind: 'bearer', key: 'BearerHttp' }, - ]) - ).toEqual(['setBearer']); - }); - - it('emits setBasicAuth for basic schemes', () => { - expect(authSetterNames([{ kind: 'basic', key: 'Basic' }])).toEqual(['setBasicAuth']); - }); - - it('names a sole apiKey scheme setApiKey regardless of its `in`', () => { - expect( - authSetterNames([{ kind: 'apiKeyCookie', key: 'CookieKey', cookieName: 'sid' }]) - ).toEqual(['setApiKey']); - }); - - it('disambiguates several apiKey schemes with the PascalCased key', () => { - expect( - authSetterNames([ - { kind: 'apiKeyHeader', key: 'HeaderKey', headerName: 'X-API-Key' }, - { kind: 'apiKeyQuery', key: 'QueryKey', paramName: 'api_key' }, - ]) - ).toEqual(['setApiKeyHeaderKey', 'setApiKeyQueryKey']); - }); - - it('orders the full surface bearer → basic → apiKey (emission order)', () => { - expect(authSetterNames(allKinds)).toEqual([ - 'setBearer', - 'setBasicAuth', - 'setApiKeyHeaderKey', - 'setApiKeyQueryKey', - 'setApiKeyCookieKey', - ]); - }); -}); diff --git a/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts b/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts index 16b4238172..bfef0d41a0 100644 --- a/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts +++ b/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts @@ -108,7 +108,6 @@ describe('emitClientSingleFile (package arm)', () => { { serverUrl: 'https://x/\u2029path' } ); expect(out).toContain('serverUrl: "https://x/\\u2029path"'); - expect(out).toContain('client.auth.apiKey("k\\u2028evil", value)'); expect(out).not.toContain('\u2028'); expect(out).not.toContain('\u2029'); }); @@ -150,14 +149,15 @@ describe('emitClientSingleFile (package arm)', () => { expect(emit(model)).toContain('export function isCat('); }); - it('emits core destructure and auth sugar bound to the instance', () => { + it('exports the core destructure, and no per-scheme credential setters', () => { expect(output).toContain('export const { configure, use } = client;'); - expect(output).toContain('export const setBearer = client.auth.bearer;'); - // Sole apiKey scheme → unsuffixed setter, scheme key baked into the closure. - expect(output).toContain( - 'export const setApiKey = (value: TokenProvider) => client.auth.apiKey("cookieAuth", value);' - ); - expect(output).not.toContain('setBasicAuth'); + // Credentials are set through `configure({ auth })` or `client.auth.*`. A setter per + // scheme gave the same act a third spelling and a name operations had to avoid. + expect(output).not.toContain('export const setBearer'); + expect(output).not.toContain('export const setApiKey'); + expect(output).not.toContain('export const setBasicAuth'); + // What tells the runtime which credentials an operation needs is the descriptor. + expect(output).toContain('security: [[{ scheme: "bearerAuth"'); }); it('emits flat sugar one-liners forwarding to the grouped client methods', () => { @@ -293,26 +293,6 @@ describe('emitClientSingleFile (package arm)', () => { expect(out).toContain('result: SearchResult;'); // the schema type, inlined }); - it('suffixes apiKey setters when several apiKey schemes exist; emits setBasicAuth for basic', () => { - const out = emit( - modelWith([getOrder], { - schemas: SCHEMAS, - securitySchemes: [ - { kind: 'basic', key: 'basicAuth' }, - { kind: 'apiKeyHeader', key: 'keyA', headerName: 'X-A' }, - { kind: 'apiKeyQuery', key: 'keyB', paramName: 'b' }, - ], - }) - ); - expect(out).toContain('export const setBasicAuth = client.auth.basic;'); - expect(out).toContain( - 'export const setApiKeyKeyA = (value: TokenProvider) => client.auth.apiKey("keyA", value);' - ); - expect(out).toContain( - 'export const setApiKeyKeyB = (value: TokenProvider) => client.auth.apiKey("keyB", value);' - ); - }); - it('handles a spec with no operations: uniform wiring over empty maps', () => { const out = emit(modelWith([]), {}); expect(out).toContain('export type Ops = Record;'); diff --git a/packages/client-generator/src/emitters/__tests__/descriptor.test.ts b/packages/client-generator/src/emitters/__tests__/descriptor.test.ts index 614768843e..14cfafc66c 100644 --- a/packages/client-generator/src/emitters/__tests__/descriptor.test.ts +++ b/packages/client-generator/src/emitters/__tests__/descriptor.test.ts @@ -23,11 +23,7 @@ const JSON_OK: ResponseBodyModel = { describe('packageIdents', () => { it('renames colliding operation ids deterministically', () => { const model = modelWith( - [ - operation({ name: 'configure' }), - operation({ name: 'createClient' }), - operation({ name: 'setBearer' }), - ], + [operation({ name: 'configure' }), operation({ name: 'createClient' })], { securitySchemes: [{ kind: 'bearer', key: 'bearerAuth' }], } @@ -35,7 +31,15 @@ describe('packageIdents', () => { const idents = packageIdents(model); expect(idents.get('configure')).toBe('configure_2'); expect(idents.get('createClient')).toBe('createClient_2'); - expect(idents.get('setBearer')).toBe('setBearer_2'); // auth sugar seeded first + }); + + it('leaves a name free once nothing exports it: no per-scheme setters, no reservation', () => { + // `setBearer` was a generated export, so an operation of that name had to be renamed. + // Credentials now go through `configure`/`client.auth`, so the name is the caller's. + const model = modelWith([operation({ name: 'setBearer' })], { + securitySchemes: [{ kind: 'bearer', key: 'bearerAuth' }], + }); + expect(packageIdents(model).get('setBearer')).toBe('setBearer'); }); it('keeps non-colliding names and sanitizes non-identifier ones', () => { diff --git a/packages/client-generator/src/emitters/auth.ts b/packages/client-generator/src/emitters/auth.ts deleted file mode 100644 index 483dc7a6f4..0000000000 --- a/packages/client-generator/src/emitters/auth.ts +++ /dev/null @@ -1,33 +0,0 @@ -// The auth *naming* conventions of the generated surface. Credential injection itself -// lives in the runtime (src/runtime/auth.ts); the wiring emitter only derives the -// public setter names bound to the client instance's `auth` members. - -import type { SecuritySchemeModel } from '../intermediate-representation/model.js'; -import { pascalCase } from './support.js'; - -/** - * Public setter name for an apiKey scheme: `setApiKey` when it's the only apiKey - * scheme (of any `in`), else `setApiKey` to disambiguate. - */ -export function apiKeySetterName(key: string, sole: boolean): string { - return sole ? 'setApiKey' : `setApiKey${pascalCase(key)}`; -} - -/** - * The public credential-setter names the client exports for a set of schemes, - * in emission order (`setBearer`, then `setBasicAuth`, then each apiKey setter). - * Also seeds the reserved-identifier set (`packageIdents`) so operation names - * can't collide with a setter. - */ -export function authSetterNames(schemes: SecuritySchemeModel[]): string[] { - const names: string[] = []; - if (schemes.some((s) => s.kind === 'bearer')) names.push('setBearer'); - if (schemes.some((s) => s.kind === 'basic')) names.push('setBasicAuth'); - const apiKeySchemes = schemes.filter( - (s) => s.kind === 'apiKeyHeader' || s.kind === 'apiKeyQuery' || s.kind === 'apiKeyCookie' - ); - for (const scheme of apiKeySchemes) { - names.push(apiKeySetterName(scheme.key, apiKeySchemes.length === 1)); - } - return names; -} diff --git a/packages/client-generator/src/emitters/client-assembly.ts b/packages/client-generator/src/emitters/client-assembly.ts index 5fdf004adb..44b836ee8d 100644 --- a/packages/client-generator/src/emitters/client-assembly.ts +++ b/packages/client-generator/src/emitters/client-assembly.ts @@ -14,9 +14,7 @@ import { allOperations, type ApiModel, type OperationModel, - type SecuritySchemeModel, } from '../intermediate-representation/model.js'; -import { apiKeySetterName } from './auth.js'; import { packageIdents, renderDescriptors } from './descriptor.js'; import { banner, type EmitOptions, HEADER, renderTitleComment } from './emit-options.js'; import { codeString } from './identifier.js'; @@ -119,7 +117,7 @@ function emitClient( const bodySection = [...ops.map((op) => renderAliases(op, ctx, 'wire')), ...wiring] .filter((section) => section.length > 0) .join('\n\n'); - const sugar = sugarSection(ops, idents, ctx, model.securitySchemes, apiKeySchemes); + const sugar = sugarSection(ops, idents, ctx); // Embed mode exports its whole public surface in place; only the package arm re-exports. const reexports = embed ? '' : reexportLines(ctx, hasSse); @@ -241,26 +239,12 @@ function clientSection(options: EmitOptions, ctx: EmitContext, model: ApiModel): function sugarSection( ops: OperationModel[], idents: Map, - ctx: EmitContext, - schemes: SecuritySchemeModel[], - apiKeySchemes: SecuritySchemeModel[] + ctx: EmitContext ): string { + // Credentials go through `configure({ auth })` or `client.auth.*` — one way per act. + // Per-scheme setters used to be exported here too, which gave the same act three + // spellings and a name per scheme that operation names then had to avoid. const lines = ['export const { configure, use } = client;']; - // Auth sugar in `authSetterNames` order: bearer, basic, then each apiKey scheme. - // The runtime's auth members close over the instance config (no `this`), so - // direct bindings are safe. - if (schemes.some((s) => s.kind === 'bearer')) { - lines.push('export const setBearer = client.auth.bearer;'); - } - if (schemes.some((s) => s.kind === 'basic')) { - lines.push('export const setBasicAuth = client.auth.basic;'); - } - for (const scheme of apiKeySchemes) { - const name = apiKeySetterName(scheme.key, apiKeySchemes.length === 1); - lines.push( - `export const ${name} = (value: TokenProvider) => client.auth.apiKey(${codeString(scheme.key)}, value);` - ); - } if (ops.length === 0) return lines.join('\n'); if (ctx.argsStyle === 'grouped') { // Grouped style: the client methods already take the grouped args shape. diff --git a/packages/client-generator/src/emitters/descriptor.ts b/packages/client-generator/src/emitters/descriptor.ts index cb75e3bbe3..d9d8ed649a 100644 --- a/packages/client-generator/src/emitters/descriptor.ts +++ b/packages/client-generator/src/emitters/descriptor.ts @@ -11,7 +11,6 @@ import { type SecuritySchemeModel, } from '../intermediate-representation/model.js'; import type { SecuritySpec } from '../runtime/types.js'; -import { authSetterNames } from './auth.js'; import { uniqueIdent } from './identifier.js'; import { isTypedMultipart } from './operation-types.js'; import type { ModelPagination } from './pagination.js'; @@ -25,12 +24,12 @@ import type { DateType } from './types.js'; /** * Operation-name → emitted-identifier plan. The full reserved set (wiring + imported - * bindings + auth sugar, computed from the model FIRST) is seeded before any operation + * bindings, computed from the model FIRST) is seeded before any operation * is sanitized, so collisions rename the operation (`configure` → `configure_2`) * deterministically regardless of document order. */ export function packageIdents(model: ApiModel): Map { - const used = new Set([...WIRING_NAMES, ...authSetterNames(model.securitySchemes)]); + const used = new Set(WIRING_NAMES); const idents = new Map(); for (const op of allOperations(model.services)) idents.set(op.name, uniqueIdent(op.name, used)); return idents; diff --git a/packages/client-generator/src/intermediate-representation/__tests__/sanitize-identifiers.test.ts b/packages/client-generator/src/intermediate-representation/__tests__/sanitize-identifiers.test.ts index f5e19d397b..b8d6c14c3c 100644 --- a/packages/client-generator/src/intermediate-representation/__tests__/sanitize-identifiers.test.ts +++ b/packages/client-generator/src/intermediate-representation/__tests__/sanitize-identifiers.test.ts @@ -135,15 +135,16 @@ describe('sanitizeIdentifiers', () => { expect(m.schemas.map((schema) => schema.name)).toEqual(['Date_2', 'Promise_2']); }); - it('renames a schema that collides with an auth setter derived from the security schemes', () => { - // A bearer scheme makes the sugar emit `export const setBearer = …`; a string-enum - // schema of the same name emits an `export const` companion — a duplicate declaration. + it('keeps a schema named after a former auth setter: nothing exports that name now', () => { + // The generator used to emit `export const setBearer = …` for a bearer scheme, which + // collided with a string-enum schema's `export const` companion. Credentials moved to + // `configure`/`client.auth`, so no generated export claims the name. const m = model([ { name: 'setBearer', schema: { kind: 'enum', scalar: 'string', values: ['a', 'b'] } }, ]); m.securitySchemes = [{ kind: 'bearer', key: 'bearerAuth' }]; sanitizeIdentifiers(m); - expect(m.schemas[0].name).toBe('setBearer_2'); + expect(m.schemas[0].name).toBe('setBearer'); }); it('renames an operation that collides with a runtime declaration', () => { diff --git a/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts b/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts index 1b42abd20c..2b07decae7 100644 --- a/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts +++ b/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts @@ -1,6 +1,5 @@ import { logger } from '@redocly/openapi-core'; -import { authSetterNames } from '../emitters/auth.js'; import { isSafeIdentifier, sanitizeIdentifier } from '../emitters/identifier.js'; import { reservedModuleNames } from '../emitters/reserved-names.js'; import { pascalCase } from '../emitters/support.js'; @@ -43,13 +42,9 @@ export function sanitizeIdentifiers(model: ApiModel): void { // Schema types land in the same module scope as everything the generator emits and // embeds, so a schema may not reuse a reserved name (the runtime's `ApiError` class, - // a satellite import like msw's `http`, the `client` const, an auth setter, …). The - // rename is mode-independent — a `--runtime` flip must not change the generated - // type names. - const reservedNames = new Set([ - ...reservedModuleNames(), - ...authSetterNames(model.securitySchemes), - ]); + // a satellite import like msw's `http`, the `client` const, …). The rename is + // mode-independent — a `--runtime` flip must not change the generated type names. + const reservedNames = new Set(reservedModuleNames()); const schemaNames = new Set(reservedNames); const schemaPascals = new Set(); const renamed = new Map(); diff --git a/tests/e2e/generate-client/auth.test.ts b/tests/e2e/generate-client/auth.test.ts index 22acd8a1b2..85b04621a3 100644 --- a/tests/e2e/generate-client/auth.test.ts +++ b/tests/e2e/generate-client/auth.test.ts @@ -30,18 +30,15 @@ describe('generate-client auth breadth (auth.yaml)', () => { expect(generated).toContain('async function resolveAuth('); expect(generated).toContain('async function resolveToken('); - // One setter per scheme kind, as instance-bound sugar. Three apiKey schemes (none sole) → keyed names. - expect(generated).toContain('export const setBearer = client.auth.bearer;'); - expect(generated).toContain('export const setBasicAuth = client.auth.basic;'); - expect(generated).toContain( - 'export const setApiKeyQueryKey = (value: TokenProvider) => client.auth.apiKey("QueryKey", value);' - ); - expect(generated).toContain( - 'export const setApiKeyHeaderKey = (value: TokenProvider) => client.auth.apiKey("HeaderKey", value);' - ); - expect(generated).toContain( - 'export const setApiKeyCookieKey = (value: TokenProvider) => client.auth.apiKey("CookieKey", value);' - ); + // Credentials go through `configure({ auth })` or `client.auth.*`; the module exports + // no per-scheme setter, so a scheme's key never becomes a reserved export name. + expect(generated).toContain('export const { configure, use } = client;'); + expect(generated).not.toContain('export const setBearer'); + expect(generated).not.toContain('export const setBasicAuth'); + expect(generated).not.toContain('export const setApiKey'); + // Each scheme still reaches the runtime through the descriptor that requires it. + expect(generated).toContain('scheme: "QueryKey"'); + expect(generated).toContain('scheme: "CookieKey"'); // Per-kind injection inside resolveAuth, driven by the descriptors' security specs. expect(generated).toContain('headers.Authorization = `Bearer ${await resolveToken(provider)}`'); @@ -83,10 +80,10 @@ describe('generate-client auth breadth (auth.yaml)', () => { // Behavioral check on a real wire. The cafe mock-server harness is bound to // cafe.yaml and heavy to clone, so we drive the generated client against a tiny - // throwaway http server instead — enough to prove (a) an async `setBearer` + // throwaway http server instead — enough to prove (a) an async bearer provider // token function resolves through the runtime's auth capability onto the // `Authorization` header and (b) a query-key scheme lands `api_key=` in the URL. - it('async setBearer resolves onto Authorization and query-key lands in the URL', () => { + it('an async bearer provider resolves onto Authorization and a query key lands in the URL', () => { // The driver owns its own throwaway http server (and points the client at it // via configure({ serverUrl })), so a single `runConsumer` runs the whole behavioral // probe — the server can't be starved by the test process's blocking spawn. @@ -98,7 +95,7 @@ describe('generate-client auth breadth (auth.yaml)', () => { dir, outdent` import * as http from 'node:http'; - import { configure, getBearer, getQuery, setBearer, setApiKeyQueryKey } from './client.js'; + import { client, configure, getBearer, getQuery } from './client.js'; const captured: Array<{ url: string; auth?: string }> = []; const server = http.createServer((req, res) => { @@ -111,9 +108,9 @@ describe('generate-client auth breadth (auth.yaml)', () => { await new Promise((r) => server.listen(0, '127.0.0.1', r)); const port = (server.address() as { port: number }).port; configure({ serverUrl: 'http://127.0.0.1:' + port }); - setBearer(async () => 'tok'); + client.auth.bearer(async () => 'tok'); await getBearer(); - setApiKeyQueryKey('secret-key'); + client.auth.apiKey('QueryKey', 'secret-key'); await getQuery({ limit: 5 }); await new Promise((r) => server.close(() => r())); process.stdout.write(JSON.stringify(captured)); diff --git a/tests/e2e/generate-client/cafe-consumer/index.ts b/tests/e2e/generate-client/cafe-consumer/index.ts index e5a0ad2b7e..4c1f80a46a 100644 --- a/tests/e2e/generate-client/cafe-consumer/index.ts +++ b/tests/e2e/generate-client/cafe-consumer/index.ts @@ -1,5 +1,6 @@ import { ApiError, + client, createOrder, deleteMenuItem, deleteOrder, @@ -10,8 +11,6 @@ import { listOrderItems, listOrders, registerOAuth2Client, - setApiKey, - setBearer, updateOrder, createMenuItem, isBeverage, @@ -42,8 +41,8 @@ async function main(): Promise { // Set credentials once. Every OAuth2/bearer operation now sends // `Authorization: Bearer `, and every ApiKey operation sends the // `X-API-Key` header. Operations declared `security: []` send neither. - setBearer('test-bearer-token'); - setApiKey('test-api-key'); + client.auth.bearer('test-bearer-token'); + client.auth.apiKey('ApiKey', 'test-api-key'); results.push( await step('listMenuItems', () => diff --git a/tests/e2e/generate-client/cafe.snapshot.ts b/tests/e2e/generate-client/cafe.snapshot.ts index 2ca73434f1..72fa62ce91 100644 --- a/tests/e2e/generate-client/cafe.snapshot.ts +++ b/tests/e2e/generate-client/cafe.snapshot.ts @@ -2214,8 +2214,6 @@ export function createClient< export const client = createClient(OPERATIONS, { serverUrl: "https://api.cafe.redocly.com", clientHeader: "redocly-client-generator" }); export const { configure, use } = client; -export const setBearer = client.auth.bearer; -export const setApiKey = (value: TokenProvider) => client.auth.apiKey("ApiKey", value); export const listMenuItems = (params: { /** * Use the `endCursor` as a value for the `after` parameter to get the next page. diff --git a/tests/e2e/generate-client/cafe.test.ts b/tests/e2e/generate-client/cafe.test.ts index 0722c4d76e..2ada6eb64f 100644 --- a/tests/e2e/generate-client/cafe.test.ts +++ b/tests/e2e/generate-client/cafe.test.ts @@ -274,10 +274,10 @@ describe('generate-client end-to-end (cafe.yaml)', () => { expect(entry!.headers['x-request-id']).toBe('11111111-2222-3333-4444-555555555555'); }); - // The consumer calls setBearer()/setApiKey() once; every OAuth2 operation must + // The consumer sets each credential once on the instance; every OAuth2 operation must // then carry the bearer header, every ApiKey operation the X-API-Key header, // and `security: []` operations neither. - test('setBearer() injects Authorization on OAuth2 operations (getOrderById)', () => { + test('a bearer credential injects Authorization on OAuth2 operations (getOrderById)', () => { const entry = log.find( (e) => e.method === 'GET' && e.url === '/orders/ord_01h1s5z6vf2mm1mz3hevnn9va7' ); @@ -285,7 +285,7 @@ describe('generate-client end-to-end (cafe.yaml)', () => { expect(entry!.headers['authorization']).toBe('Bearer test-bearer-token'); }); - test('setApiKey() injects X-API-Key on ApiKey operations (getRevenue)', () => { + test('an apiKey credential injects X-API-Key on ApiKey operations (getRevenue)', () => { const entry = log.find((e) => e.method === 'GET' && e.url.startsWith('/revenue')); expect(entry).toBeDefined(); expect(entry!.headers['x-api-key']).toBe('test-api-key'); diff --git a/tests/e2e/generate-client/examples/configure-and-middleware/README.md b/tests/e2e/generate-client/examples/configure-and-middleware/README.md index 62b744d93e..3c7472ed3e 100644 --- a/tests/e2e/generate-client/examples/configure-and-middleware/README.md +++ b/tests/e2e/generate-client/examples/configure-and-middleware/README.md @@ -9,7 +9,7 @@ from the hand-written `src/main.ts`, so it survives regeneration - `use()` middleware targeting `ctx.operation.id` / `ctx.operation.tags` (typed literal unions — typos fail the build), mutating the request body (`ctx.body` edits are sent), and observing each attempt's raw `Response`. -- The generated `setApiKey()` auth setter. +- Setting a credential for one scheme with `client.auth.apiKey()`. - A per-call header via the trailing `RequestOptions` argument. - `ApiError` handling with the spec's problem document on `error.body`. diff --git a/tests/e2e/generate-client/examples/configure-and-middleware/src/main.ts b/tests/e2e/generate-client/examples/configure-and-middleware/src/main.ts index b33ca4d3fa..57cd824aee 100644 --- a/tests/e2e/generate-client/examples/configure-and-middleware/src/main.ts +++ b/tests/e2e/generate-client/examples/configure-and-middleware/src/main.ts @@ -4,17 +4,17 @@ // `Retry-After` honored; per-call override via `init.retry`). // * `use()`: middleware that targets `ctx.operation.id` — a LITERAL UNION of this // spec's operation ids, so a typo fails the build instead of silently never matching. -// * `setApiKey()`: per-scheme auth sugar; injected only on operations whose +// * `client.auth.apiKey()`: a credential per scheme; injected only on operations whose // `security` names the scheme. // * `ApiError`: a non-2xx response throws, carrying the decoded problem document // on `error.body`. import { ApiError, + client, configure, createPayment, getPayment, listPayments, - setApiKey, use, type ProblemDetails, } from './api/client.js'; @@ -61,7 +61,7 @@ configure({ // Auth sugar generated from the spec's `ApiKeyAuth` scheme: every operation whose // `security` requires it gets an `X-Api-Key` header — nothing to wire by hand. -setApiKey('demo-key-123'); +client.auth.apiKey('ApiKey', 'demo-key-123'); use({ onRequest: (ctx) => { diff --git a/tests/e2e/generate-client/package-runtime-consumer/index.ts b/tests/e2e/generate-client/package-runtime-consumer/index.ts index 68ec914434..900f078cda 100644 --- a/tests/e2e/generate-client/package-runtime-consumer/index.ts +++ b/tests/e2e/generate-client/package-runtime-consumer/index.ts @@ -1,4 +1,4 @@ -import { client, configure_2, createOrder, getOrder, setBearer, streamEvents, use } from './api.js'; +import { client, configure_2, createOrder, getOrder, streamEvents, use } from './api.js'; async function main(): Promise { const middlewareIds: string[] = []; @@ -7,7 +7,7 @@ async function main(): Promise { middlewareIds.push(ctx.operation.id); }, }); - setBearer('test-token'); + client.auth.bearer('test-token'); // Flat sugar: positional path value forwarded under the wire name `order-id`. const order = await getOrder('o-1', { expand: 'items' }); diff --git a/tests/e2e/generate-client/split.test.ts b/tests/e2e/generate-client/split.test.ts index 2ea790e224..34350146b5 100644 --- a/tests/e2e/generate-client/split.test.ts +++ b/tests/e2e/generate-client/split.test.ts @@ -52,7 +52,7 @@ describe('generate-client end-to-end (--output-mode split)', () => { 'export const client = createClient(OPERATIONS,' ); expect(entrySrc).toContain('export const { configure, use } = client;'); - expect(entrySrc).toContain('export const setBearer = client.auth.bearer;'); + expect(entrySrc).toContain('export const { configure, use } = client;'); // Schemas holds the model types and the discriminated-union guards. const schemasSrc = readFileSync(schemasFile, 'utf-8'); From 9ed3ffd4375bd947c0418f1d15b4200e43f89acd Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 18 Aug 2026 16:34:01 +0300 Subject: [PATCH 197/211] test: assert operation-name sanitization on the operation, not on a setter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The injection bar claimed to check that a hostile operationId becomes a single valid identifier "in the flat call sugar", with a pattern that cannot match operation sugar: a generic parameter list and a return-type annotation sit between the name and the arrow. What satisfied it was the credential setter, which the hostile security scheme also produced — so removing the setters is what surfaced this. It now captures the exported name and the client method it forwards to and requires them to be the same identifier, which is the invariant the comment always described. --- tests/e2e/generate-client/identifier-injection.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/e2e/generate-client/identifier-injection.test.ts b/tests/e2e/generate-client/identifier-injection.test.ts index ef4140095b..e93697bc2c 100644 --- a/tests/e2e/generate-client/identifier-injection.test.ts +++ b/tests/e2e/generate-client/identifier-injection.test.ts @@ -71,9 +71,13 @@ describe('generate-client identifier / comment injection', () => { expect(src).not.toMatch(/\*\/\s*;globalThis/); // No payload survives as a top-level statement (only inside identifiers/comments). expect(src).not.toMatch(/^\s*globalThis\.PWNED/m); - // The operation name became a single valid identifier (no parens/spaces/semicolons) - // in the flat call sugar. - expect(src).toMatch(/export const [A-Za-z_$][A-Za-z0-9_$]* = \([^)]*\) => client\./); + // The operation name became a single valid identifier (no parens, spaces, or + // semicolons), and the flat sugar forwards to the client method of that same name. + const flat = src.match( + /export const ([A-Za-z_$][A-Za-z0-9_$]*) = [^\n]*=> client\.([A-Za-z_$][A-Za-z0-9_$]*)\(/ + ); + expect(flat, 'no flat call sugar found in the generated client').not.toBeNull(); + expect(flat![1]).toBe(flat![2]); // Strongest proof: the whole file type-checks. Injected statements would not. const tsc = spawnSync( From a36cc78cd0853fdb38c82af309fec8ece57f3d8a Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 18 Aug 2026 16:54:46 +0300 Subject: [PATCH 198/211] fix(client-generator)!: flat iterators take the flat argument shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A flat free function had two argument shapes: `listOrders({ limit: 20 })` for the call, and `listOrders.pages({ params: { limit: 20 } })` for its iterators, which were bound straight from the grouped client method. The guide documented that as an exception, which was the tell — review asked twice why one function changes its interface, and this was the remaining case. The emitter now wraps the iterators the same way it wraps the call, so `.pages()` and `.items()` take exactly the arguments the function takes. `init` is a plain `RequestOptions` rather than the envelope-aware generic, because `envelope` means nothing for an iterator. Grouped mode is untouched: it already re-exported the client methods. One input shape per generated client, and the guide now says so instead of carving out an exception. --- .changeset/agent-friendly-generators.md | 2 ++ docs/@v2/guides/use-generated-client.md | 6 +++--- .../client-generator/src/__tests__/index.test.ts | 2 +- .../__snapshots__/client-assembly.test.ts.snap | 16 ++++++++++++++-- .../emitters/__tests__/client-assembly.test.ts | 11 +++++++++-- .../src/emitters/render-client.ts | 16 +++++++++++++++- .../examples/pagination/src/main.ts | 4 ++-- .../pagination-consumer/index-abort.ts | 5 +---- .../pagination-consumer/index-offset.ts | 4 ++-- .../pagination-consumer/index-package.ts | 2 +- .../generate-client/pagination-consumer/index.ts | 15 ++++++++------- tests/e2e/generate-client/pagination.test.ts | 16 ++++++++++++---- tests/e2e/generate-client/redocly-config.test.ts | 2 +- 13 files changed, 71 insertions(+), 30 deletions(-) diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index ad92d92b7a..7ed0551b3f 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -16,6 +16,8 @@ Added an `eject-generator` command that vendors any built-in generator, with its Renamed pagination operation extension from `x-redocly-pagination` to `x-redoclyPagination`. The previous name is no longer read. +**Note**: with `argsStyle: flat` (the default), a free function's `.pages()`/`.items()` now take the same arguments as the function itself — `listOrders.pages({ limit: 20 })` rather than `listOrders.pages({ params: { limit: 20 } })`. Grouped mode is unchanged, and the compiler points at every call site that needs the edit. + **Note**: the generated TypeScript client no longer exports per-scheme credential setters (`setBearer`, `setBasicAuth`, `setApiKey`). Set credentials with `configure({ auth: … })` or on the instance with `client.auth.bearer(…)`, `client.auth.basic(…)`, and `client.auth.apiKey('', …)`. One consequence is welcome: a setter name is no longer reserved, so an operation or schema of that name keeps it. **Note**: the TypeScript client generator is now selected as `typescript` instead of `sdk`, matching the language-named generators. Update `client.generators` lists and `--generator` flags; the old name fails with a message that points at the rename. diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index 4d3ec3ec53..918d0594c1 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -936,9 +936,9 @@ for await (const page of client.listOrders.pages()) { } ``` -The flat free functions keep both iterators. -The flat function itself takes positional arguments. -But its `.pages`/`.items` always take the grouped shape, because they are the client method's iterators. +The flat free functions keep both iterators, and the iterators take the same arguments as the function they hang on. +With `--args-style flat`, `listOrders({ limit: 20 })` and `listOrders.pages({ limit: 20 })` have the same shape. +With `--args-style grouped`, both take the grouped object. To resume, pass the advance parameter in the initial args. Iteration then starts from that point, not from the beginning. diff --git a/packages/client-generator/src/__tests__/index.test.ts b/packages/client-generator/src/__tests__/index.test.ts index 7b51a6ae71..f2d3a7df62 100644 --- a/packages/client-generator/src/__tests__/index.test.ts +++ b/packages/client-generator/src/__tests__/index.test.ts @@ -314,7 +314,7 @@ describe('generateClient — end-to-end orchestration', () => { ); expect(contents).toContain('item: string;'); expect(contents).toContain( - '{ pages: client.listOrders.pages, items: client.listOrders.items });' + 'init: RequestOptions = {}) => client.listOrders.pages({ params }, init)' ); }); diff --git a/packages/client-generator/src/emitters/__tests__/__snapshots__/client-assembly.test.ts.snap b/packages/client-generator/src/emitters/__tests__/__snapshots__/client-assembly.test.ts.snap index 264b103e04..36bfe6f414 100644 --- a/packages/client-generator/src/emitters/__tests__/__snapshots__/client-assembly.test.ts.snap +++ b/packages/client-generator/src/emitters/__tests__/__snapshots__/client-assembly.test.ts.snap @@ -171,7 +171,13 @@ export const { configure, use } = client; export const listOrders = Object.assign((params: { cursor?: string; limit?: string; -} = {}, init?: I): Promise, I>> => client.listOrders({ params }, init) as Promise, I>>, { pages: client.listOrders.pages, items: client.listOrders.items }); +} = {}, init?: I): Promise, I>> => client.listOrders({ params }, init) as Promise, I>>, { pages: (params: { + cursor?: string; + limit?: string; +} = {}, init: RequestOptions = {}) => client.listOrders.pages({ params }, init), items: (params: { + cursor?: string; + limit?: string; +} = {}, init: RequestOptions = {}) => client.listOrders.items({ params }, init) }); export const getOrder = (orderId: string, params: { expand?: string; } = {}, init?: I): Promise, I>> => client.getOrder({ orderId, params }, init) as Promise, I>>; @@ -276,7 +282,13 @@ export const { configure, use } = client; export const listOrders = Object.assign((params: { cursor?: string; limit?: string; -} = {}, init: RequestOptions = {}) => client.listOrders({ params }, init), { pages: client.listOrders.pages, items: client.listOrders.items }); +} = {}, init: RequestOptions = {}) => client.listOrders({ params }, init), { pages: (params: { + cursor?: string; + limit?: string; +} = {}, init: RequestOptions = {}) => client.listOrders.pages({ params }, init), items: (params: { + cursor?: string; + limit?: string; +} = {}, init: RequestOptions = {}) => client.listOrders.items({ params }, init) }); export const getOrder = (orderId: string, params: { expand?: string; } = {}, init: RequestOptions = {}) => client.getOrder({ orderId, params }, init); 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 bfef0d41a0..9fe36c4e0e 100644 --- a/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts +++ b/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts @@ -467,13 +467,20 @@ describe('emitClientSingleFile — pagination', () => { expect(out).toContain('pagination: { style: "cursor", param: "cursor",'); }); - it('wraps the flat sugar in Object.assign, preserving .pages/.items', () => { + it('attaches .pages/.items that take the same flat arguments as the call', () => { const out = emit(PAGINATED, { pagination: config }); expect(out).toContain( 'export const listOrders = Object.assign((params: {' ); expect(out).toContain('=> client.listOrders({ params }, init) as Promise<'); - expect(out).toContain('{ pages: client.listOrders.pages, items: client.listOrders.items });'); + // One exported function, one argument shape: the iterators translate to the + // client method's grouped form exactly as the call above does. + expect(out).toContain( + 'init: RequestOptions = {}) => client.listOrders.pages({ params }, init)' + ); + expect(out).toContain( + 'init: RequestOptions = {}) => client.listOrders.items({ params }, init)' + ); // Non-paginated siblings keep the plain arrow. expect(out).toContain( 'export const getOrder = (orderId: string, params: {' diff --git a/packages/client-generator/src/emitters/render-client.ts b/packages/client-generator/src/emitters/render-client.ts index a73f21dbc5..ff2d94bc75 100644 --- a/packages/client-generator/src/emitters/render-client.ts +++ b/packages/client-generator/src/emitters/render-client.ts @@ -392,7 +392,21 @@ export function renderFlatSugar(op: OperationModel, ident: string, ctx: EmitCont })() : `(${params}) => client.${ident}(${args}, init)`; if (!ctx.pagination?.has(op.name)) return `export const ${ident} = ${fn};`; - return `export const ${ident} = Object.assign(${fn}, { pages: client.${ident}.pages, items: client.${ident}.items });`; + // The iterators take the SAME flat arguments as the call above. Binding the client + // method's grouped iterators here would give one exported function two argument + // shapes — `listOrders({ limit: 20 })` beside `listOrders.pages({ params: { limit: 20 } })`. + // `init` is a plain `RequestOptions`: `envelope` has no meaning for an iterator. + const iterParams = argListText( + op, + pathParams.map((p) => p.param), + new Map(pathParams.map((p) => [p.param.name, p.ident])), + ctx, + 'init: RequestOptions = {}' + ); + const iterators = ['pages', 'items'] + .map((kind) => `${kind}: (${iterParams}) => client.${ident}.${kind}(${args}, init)`) + .join(', '); + return `export const ${ident} = Object.assign(${fn}, { ${iterators} });`; } /** diff --git a/tests/e2e/generate-client/examples/pagination/src/main.ts b/tests/e2e/generate-client/examples/pagination/src/main.ts index 43647fdd2d..9a2b550f5c 100644 --- a/tests/e2e/generate-client/examples/pagination/src/main.ts +++ b/tests/e2e/generate-client/examples/pagination/src/main.ts @@ -34,12 +34,12 @@ configure({ fetch: canned }); // `.items()` walks every order across every page — the cursor plumbing is invisible, // and each `order` is the statically computed element type (`Order`). -for await (const order of listOrders.items({ params: { limit: 20 } })) { +for await (const order of listOrders.items({ limit: 20 })) { console.log(`${order.id}: ${order.drink} (${order.status})`); } // `.pages()` when you need page-level access (progress reporting, batch writes). let pageNumber = 0; -for await (const page of listOrders.pages({ params: { limit: 20 } })) { +for await (const page of listOrders.pages({ limit: 20 })) { console.log(`page ${++pageNumber}: ${page.orders.length} orders`); } diff --git a/tests/e2e/generate-client/pagination-consumer/index-abort.ts b/tests/e2e/generate-client/pagination-consumer/index-abort.ts index 36ecc4314c..4b9df83acb 100644 --- a/tests/e2e/generate-client/pagination-consumer/index-abort.ts +++ b/tests/e2e/generate-client/pagination-consumer/index-abort.ts @@ -9,10 +9,7 @@ async function main(): Promise { let error: string | null = null; try { - for await (const order of listOrders.items( - { params: { limit: 2 } }, - { signal: controller.signal } - )) { + for await (const order of listOrders.items({ limit: 2 }, { signal: controller.signal })) { void order; received++; if (received === 1) { diff --git a/tests/e2e/generate-client/pagination-consumer/index-offset.ts b/tests/e2e/generate-client/pagination-consumer/index-offset.ts index 30f53281e7..0b19996174 100644 --- a/tests/e2e/generate-client/pagination-consumer/index-offset.ts +++ b/tests/e2e/generate-client/pagination-consumer/index-offset.ts @@ -5,13 +5,13 @@ import { listMenuItems, OPERATIONS } from './api-offset.js'; // each page's item count until an empty page arrives. async function main(): Promise { const names: string[] = []; - for await (const item of listMenuItems.items({ params: { limit: 2 } })) { + for await (const item of listMenuItems.items({ limit: 2 })) { names.push(item.name); // compile-time: `item` is `MenuItem` } // The trailing empty page IS yielded (every page arrives before the stop check). const pageSizes: number[] = []; - for await (const page of listMenuItems.pages({ params: { limit: 2 } })) { + for await (const page of listMenuItems.pages({ limit: 2 })) { pageSizes.push(page.items.length); } diff --git a/tests/e2e/generate-client/pagination-consumer/index-package.ts b/tests/e2e/generate-client/pagination-consumer/index-package.ts index a73e8650f8..a3d63542da 100644 --- a/tests/e2e/generate-client/pagination-consumer/index-package.ts +++ b/tests/e2e/generate-client/pagination-consumer/index-package.ts @@ -5,7 +5,7 @@ import { listOrders } from './api-package.js'; // package — one full `.items()` walk proves the capability is wired there too. async function main(): Promise { const ids: string[] = []; - for await (const order of listOrders.items({ params: { limit: 2 } })) { + for await (const order of listOrders.items({ limit: 2 })) { ids.push(order.id); } diff --git a/tests/e2e/generate-client/pagination-consumer/index.ts b/tests/e2e/generate-client/pagination-consumer/index.ts index 183c5dfbf3..2231d97eed 100644 --- a/tests/e2e/generate-client/pagination-consumer/index.ts +++ b/tests/e2e/generate-client/pagination-consumer/index.ts @@ -4,29 +4,30 @@ import { listOrders } from './api.js'; // Exercises `.items()` across three cursor pages, `.pages()` page-level access, and // resume from a caller-provided cursor — while the caller's args are never mutated. async function main(): Promise { - // `.items()`: the flat sugar preserves the method-attached iterators; every request - // forwards the caller's `limit` alongside the advancing cursor. - const firstArgs = { params: { limit: 2 } }; + // `.items()`: the iterators take the SAME flat arguments as the call itself — the + // query params object, not a grouped `{ params }`. Every request forwards the + // caller's `limit` alongside the advancing cursor. + const firstArgs = { limit: 2 }; const ids: string[] = []; for await (const order of listOrders.items(firstArgs)) { ids.push(order.id); // compile-time: `order` is `Order` } // The iterator clones params per request — the cursor never leaks into caller args. - const firstCursorLeaked = 'cursor' in firstArgs.params; + const firstCursorLeaked = 'cursor' in firstArgs; // `.pages()`: whole pages, typed as the raw response — sizes pin the 2+2+1 layout. const pageSizes: number[] = []; - for await (const page of listOrders.pages({ params: { limit: 2 } })) { + for await (const page of listOrders.pages({ limit: 2 })) { pageSizes.push(page.orders.length); } // Resume: a caller-provided initial cursor starts iteration at that page. - const resumeArgs = { params: { cursor: 'c2', limit: 2 } }; + const resumeArgs = { cursor: 'c2', limit: 2 }; const resumedIds: string[] = []; for await (const order of listOrders.items(resumeArgs)) { resumedIds.push(order.id); } - const resumeCursorAfter = resumeArgs.params.cursor; + const resumeCursorAfter = resumeArgs.cursor; process.stdout.write( JSON.stringify({ ids, firstCursorLeaked, pageSizes, resumedIds, resumeCursorAfter }) + '\n' diff --git a/tests/e2e/generate-client/pagination.test.ts b/tests/e2e/generate-client/pagination.test.ts index 733b47ac15..6d857082c1 100644 --- a/tests/e2e/generate-client/pagination.test.ts +++ b/tests/e2e/generate-client/pagination.test.ts @@ -104,11 +104,17 @@ describe('generate-client pagination consumer', () => { expect(api).toContain( 'getOrder: { id: "getOrder", method: "GET", path: "/orders/{orderId}", params: [{ name: "orderId", in: "path" }] }' ); - // …and the flat sugar preserves `.pages`/`.items` via Object.assign. + // …and the flat sugar attaches `.pages`/`.items` that take the SAME flat arguments + // as the call, so one exported function never has two argument shapes. expect(api).toContain( 'export const listOrders = Object.assign((params: {' ); - expect(api).toContain('{ pages: client.listOrders.pages, items: client.listOrders.items });'); + expect(api).toContain( + 'init: RequestOptions = {}) => client.listOrders.pages({ params }, init)' + ); + expect(api).toContain( + 'init: RequestOptions = {}) => client.listOrders.items({ params }, init)' + ); expect(api).not.toContain('client.listMenuItems.pages'); // Inline mode embeds paginate.ts (the infinite-loop guard is its fingerprint). expect(api).toContain('// ─── Embedded runtime'); @@ -121,7 +127,7 @@ describe('generate-client pagination consumer', () => { ); expect(offset).toContain('item: MenuItem;'); expect(offset).toContain( - '{ pages: client.listMenuItems.pages, items: client.listMenuItems.items });' + 'init: RequestOptions = {}) => client.listMenuItems.pages({ params }, init)' ); // …precedence keeps the extension's cursor rule on listOrders (not the convention)… expect(offset).toContain( @@ -139,7 +145,9 @@ describe('generate-client pagination consumer', () => { expect(pkg).toContain( 'pagination: { style: "cursor", param: "cursor", limitParam: "limit", nextCursor: "/nextCursor", items: "/orders" }' ); - expect(pkg).toContain('{ pages: client.listOrders.pages, items: client.listOrders.items });'); + expect(pkg).toContain( + 'init: RequestOptions = {}) => client.listOrders.pages({ params }, init)' + ); }, 60_000); test('typecheck gate: all three generated clients + consumer scripts, strict', () => { diff --git a/tests/e2e/generate-client/redocly-config.test.ts b/tests/e2e/generate-client/redocly-config.test.ts index 2e33add89e..fbb7521bf7 100644 --- a/tests/e2e/generate-client/redocly-config.test.ts +++ b/tests/e2e/generate-client/redocly-config.test.ts @@ -303,7 +303,7 @@ describe('generate-client redocly.yaml config', () => { // The convention fits the cursor-style list operations -> descriptor pagination… expect(out).toContain('pagination: {'); // …and the flat sugar preserves the method-attached iterators. - expect(out).toContain('items: client.listOrders.items'); + expect(out).toContain('=> client.listOrders.items({ params }, init)'); rmSync(dir, { recursive: true, force: true }); }, 60_000); From 0b8a1fe90839eadbaa1ba92ee49c75bf2b26c8c7 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 18 Aug 2026 17:09:51 +0300 Subject: [PATCH 199/211] docs: show how to make binName a real command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review pointed out that `binName` names the command but installs nothing, and that the docs never showed how to close that gap — they said twice to "point the `bin` field at the compiled file" without an example. The CLI section now has a "Ship it as a real command" step-by-step: `"type": "module"` with the confusing tsx error it prevents, a package.json declaring `bin` plus a tsc build, and `npm link`. It also says to keep the `bin` key and `binName` identical, or the help output names a command that does not exist, and that `binName` is cli-only — the language SDKs are libraries with no command. The config reference, the command page, and the `--bin-name` flag description now state that it installs nothing and point at that section. --- docs/@v2/commands/generate-client.md | 42 +++++++++++----------- docs/@v2/configuration/reference/client.md | 2 +- docs/@v2/guides/use-generated-client.md | 30 ++++++++++++++-- packages/cli/src/index.ts | 2 +- 4 files changed, 50 insertions(+), 26 deletions(-) diff --git a/docs/@v2/commands/generate-client.md b/docs/@v2/commands/generate-client.md index c6135ac9c5..ea38f918a1 100644 --- a/docs/@v2/commands/generate-client.md +++ b/docs/@v2/commands/generate-client.md @@ -71,27 +71,27 @@ redocly generate-client [--help] [--version] ## Options -| Option | Type | Description | -| ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `api` | string | The file path to the OpenAPI description, a URL, or an `apis:` alias. Omit it to generate a client for each api that has a `client` block or `clientOutput`. | -| `--output`, `-o` | string | The output path (it must end in `.ts`). In multi-file modes, this is the entry file. Defaults to the `clientOutput` of the api, else `.client.ts` next to the configuration file. Use this option only when you generate one API. | -| `--output-mode` | string | The file layout. See [Choose an output mode](#choose-an-output-mode).
**Possible values:** `single`, `split`. Default: `single`. | -| `--runtime` | string | The location of the client engine. See [Choose a runtime](#choose-a-runtime).
**Possible values:** `inline`, `package`. Default: `inline`. | -| `--import-ext` | string | The extension in the generated relative imports. See [Run with Node directly](../guides/use-generated-client.md#run-with-node-directly).
**Possible values:** `js` (the tsc/bundler convention), `ts` (for Node's built-in type stripping). Default: `js`. | -| `--generator` | [string] | The generator to run: a built-in name, or the path or package of a custom generator. Repeat the flag to run more than one generator. Default value is `typescript`. See [Generators](../guides/use-generated-client.md#generators) for the full list. | -| `--args-style` | string | Sets how you pass inputs to operations. See [Argument style](../guides/use-generated-client.md#argument-style).
**Possible values:** `flat`, `grouped`. Default: `flat`. | -| `--error-mode` | string | Sets how operations report HTTP errors. See [Error handling](../guides/use-generated-client.md#error-handling).
**Possible values:** `throw`, `result`. Default: `throw`. | -| `--date-type` | string | The type of the `date`/`date-time` fields. If you use `Date`, also use the `transformers` generator.
**Possible values:** `string`, `Date`. Default: `string`. | -| `--mock-data` | string | The data mode for the `mock` generator.
**Possible values:** `static` (deterministic literals), `faker` (`@faker-js/faker` calls). Default: `static`. | -| `--mock-seed` | number | The seed for `faker`-mode mocks. Use it to get reproducible data. The command ignores it in `static` mode. | -| `--server-url` | string | Overrides the default server URL in the client. The option accepts an absolute URL (`https://api.example.com`) or a relative URL (`/v1`). Defaults to `servers[0].url`. The app can also change the server URL at runtime with `createClient({ serverUrl })` or `configure({ serverUrl })`. See [Authentication](../guides/use-generated-client.md#authentication) in the usage guide. | -| `--setup` | string | The path to a publisher setup module that the command includes in the client. Use it to pre-configure defaults, for example the server URL, retries, headers, and middleware. A published SDK then contains these defaults. See [Publisher defaults](../guides/customize-client-generation.md#publisher-defaults). | -| `--bin-name` | string | The command name that the `cli` generator prints in the help output. The generator also uses it to derive the names of the credential environment variables. Defaults to the output file name (without extension) with non-word characters converted to `-`. | -| `--docs` | boolean | Also write the reference documentation for what this run generates: one Markdown page for each selected generator that documents itself (the CLI, and each SDK). Default value is `false`. | -| `--go-package` | string | The package clause in the output of the `go` generator. It must be a valid Go package name (lowercase letters, digits, and `_`; it must not start with a digit or be a keyword). Default value is `client`. | -| `--config` | string | Specify the path to the [configuration file](#generate-from-the-configuration-file). | -| `--help` | boolean | Display help. | -| `--version` | boolean | Display version number. | +| Option | Type | Description | +| ---------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `api` | string | The file path to the OpenAPI description, a URL, or an `apis:` alias. Omit it to generate a client for each api that has a `client` block or `clientOutput`. | +| `--output`, `-o` | string | The output path (it must end in `.ts`). In multi-file modes, this is the entry file. Defaults to the `clientOutput` of the api, else `.client.ts` next to the configuration file. Use this option only when you generate one API. | +| `--output-mode` | string | The file layout. See [Choose an output mode](#choose-an-output-mode).
**Possible values:** `single`, `split`. Default: `single`. | +| `--runtime` | string | The location of the client engine. See [Choose a runtime](#choose-a-runtime).
**Possible values:** `inline`, `package`. Default: `inline`. | +| `--import-ext` | string | The extension in the generated relative imports. See [Run with Node directly](../guides/use-generated-client.md#run-with-node-directly).
**Possible values:** `js` (the tsc/bundler convention), `ts` (for Node's built-in type stripping). Default: `js`. | +| `--generator` | [string] | The generator to run: a built-in name, or the path or package of a custom generator. Repeat the flag to run more than one generator. Default value is `typescript`. See [Generators](../guides/use-generated-client.md#generators) for the full list. | +| `--args-style` | string | Sets how you pass inputs to operations. See [Argument style](../guides/use-generated-client.md#argument-style).
**Possible values:** `flat`, `grouped`. Default: `flat`. | +| `--error-mode` | string | Sets how operations report HTTP errors. See [Error handling](../guides/use-generated-client.md#error-handling).
**Possible values:** `throw`, `result`. Default: `throw`. | +| `--date-type` | string | The type of the `date`/`date-time` fields. If you use `Date`, also use the `transformers` generator.
**Possible values:** `string`, `Date`. Default: `string`. | +| `--mock-data` | string | The data mode for the `mock` generator.
**Possible values:** `static` (deterministic literals), `faker` (`@faker-js/faker` calls). Default: `static`. | +| `--mock-seed` | number | The seed for `faker`-mode mocks. Use it to get reproducible data. The command ignores it in `static` mode. | +| `--server-url` | string | Overrides the default server URL in the client. The option accepts an absolute URL (`https://api.example.com`) or a relative URL (`/v1`). Defaults to `servers[0].url`. The app can also change the server URL at runtime with `createClient({ serverUrl })` or `configure({ serverUrl })`. See [Authentication](../guides/use-generated-client.md#authentication) in the usage guide. | +| `--setup` | string | The path to a publisher setup module that the command includes in the client. Use it to pre-configure defaults, for example the server URL, retries, headers, and middleware. A published SDK then contains these defaults. See [Publisher defaults](../guides/customize-client-generation.md#publisher-defaults). | +| `--bin-name` | string | The command name that the `cli` generator prints in the help output. The generator also uses it to derive the names of the credential environment variables. It does not install a command; see [Ship it as a real command](../guides/use-generated-client.md#ship-it-as-a-real-command). Defaults to the output file name (without extension) with non-word characters converted to `-`. | +| `--docs` | boolean | Also write the reference documentation for what this run generates: one Markdown page for each selected generator that documents itself (the CLI, and each SDK). Default value is `false`. | +| `--go-package` | string | The package clause in the output of the `go` generator. It must be a valid Go package name (lowercase letters, digits, and `_`; it must not start with a digit or be a keyword). Default value is `client`. | +| `--config` | string | Specify the path to the [configuration file](#generate-from-the-configuration-file). | +| `--help` | boolean | Display help. | +| `--version` | boolean | Display version number. | ## Examples diff --git a/docs/@v2/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md index 2fa137b205..c28d50bf7e 100644 --- a/docs/@v2/configuration/reference/client.md +++ b/docs/@v2/configuration/reference/client.md @@ -37,7 +37,7 @@ As an alternative, pass `pagination` to the programmatic `generateClient(...)`. | `codeSamples` | boolean | Emit `.code-samples.yaml` next to the client. This file is an OpenAPI Overlay that adds `x-codeSamples` to each operation. The samples come from each selected generator that implements `sample()`. This option is available only in the configuration file and has no flag. | | `serverUrl` | string | The server URL that the client includes as its default. If you do not set it, the client uses `servers[0].url`. | | `goPackage` | string | The package clause for the output of the `go` generator. The value must be a valid Go package name: lowercase letters, digits, and `_`, with no digit at the start, and not a keyword. An invalid value stops generation, so the generator does not emit a file that Go cannot compile. Default: `client`. | -| `binName` | string | The command name that the `cli` generator shows in the help output and uses to derive its credential environment variables. The default is the output file name (without extension), sanitized. | +| `binName` | string | The command name that the `cli` generator shows in the help output and uses to derive its credential environment variables. It does not install a command: see [Ship it as a real command](../../guides/use-generated-client.md#ship-it-as-a-real-command). The default is the output file name (without extension), sanitized. | | `cliOutput` | string | The path of a composed CLI entry. The entry includes every api that emits a cli module: from the `cli` generator by name, ejected, or included as a prerequisite. The result is one binary. You address each api by its alias, and each api has `__*` credential variables. This option is available only in the top-level `client` block. See [Compose and extend the CLI](../../guides/use-generated-client.md#compose-and-extend-the-cli). | | `options` | object | Options for each generator, keyed by generator name. The command validates each entry against the schema that the generator declares. The `python` generator accepts `models`: `dataclass` (default) or `pydantic`. See [Custom generators](../../guides/customize-client-generation.md#custom-generators). | | `docs` | boolean | Also write the reference documentation for what the run generates: one Markdown page for each selected generator that documents itself (`.cli.md`, `.python.md`, and so on). The `--docs` flag sets it too. Default `false`. | diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index 918d0594c1..d6dad92195 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -184,10 +184,34 @@ The generator itself supplies no credential store and no login command. The auth flow of each API is different, so you supply these parts. This section shows the procedure. -The CLI uses top-level `await`, so the nearest `package.json` must set `"type": "module"`. +#### Ship it as a real command + +`binName` is the name the CLI uses for itself, not an installation. +The generated file is a module until you point a `bin` field at it, and these three steps are what make `cafe` a command on your machine. + +First, the CLI uses top-level `await`, so the nearest `package.json` must set `"type": "module"`. Without this setting, `tsx` reports `Top-level await is currently not supported with the "cjs" output format`, and that message does not point to the fix. -To ship the CLI as a real bin, compile it with `tsc`. -Then point the `bin` field of `package.json` at the compiled file. + +Second, compile the entry with `tsc` and declare the compiled file as the bin: + +```json +{ + "type": "module", + "bin": { "cafe": "./dist/cafe.js" }, + "scripts": { "build": "tsc" } +} +``` + +Third, install the package, or link it while you develop: + +```sh +npm run build && npm link +cafe listOrders --limit 3 # CAFE_TOKEN from the environment +``` + +Keep the `bin` key and `binName` the same, or the help output names a command that does not exist. +For a one-off run, `npx tsx src/cafe.ts listOrders --limit 3` uses the same entry with no build step. +`binName` applies to the `cli` generator only: the `python`, `go`, and `php` SDKs are libraries, and they emit no command. ### Language SDKs diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 3e62c6d014..4f19359a54 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -902,7 +902,7 @@ yargs(hideBin(process.argv)) }, 'bin-name': { description: - "Command name for the `cli` generator's help output and credential env vars. Defaults to the output stem.", + "Command name for the `cli` generator's help output and credential env vars; it does not install a command. Defaults to the output stem.", type: 'string', requiresArg: true, }, From 9710db4116e5385b7380e89c6ce5c32f35a02fb1 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 18 Aug 2026 17:28:04 +0300 Subject: [PATCH 200/211] docs: correct what a tag group is for in the generated CLI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guide claimed a group disambiguates commands that share a name. That cannot happen: when a description declares the same operationId twice, the generator reports it and emits the second as `_2`, so command names are unique. The group organizes `--help` — which is what it is for on an API with hundreds of operations — and addressing by group stays available for the reader who just browsed that group, but it is never required. --- docs/@v2/guides/use-generated-client.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index d6dad92195..a3fdc20c14 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -57,9 +57,10 @@ npx tsx src/client.cli.ts schema createOrder # the operation's full You do not have to know which tag the operation carries. Run ` listOrders --help` to show the flags of one command. -A tag adds a group, and a group does two things. -It organizes `--help`, which matters for an API with hundreds of operations. -It also disambiguates: if two operations share a command name, the CLI reports the ambiguity and names the groups to choose from, and ` orders listOrders` addresses one of them. +A tag adds a group, and the group organizes `--help`. +This matters for an API with hundreds of operations: ` --help` lists the groups, and ` orders --help` lists the commands of one group. +A group also addresses a command (` orders listOrders`), which is what you would type after browsing that group, but it is never required. +Two commands cannot share a name: when a description declares the same `operationId` twice, the generator reports it and emits the second as `_2`. An operation with no `operationId` still gets a command. The generator derives the name from the method and the path (`GET /pets` becomes `getPets`, and `GET /pets/{id}` becomes `getPetsId`), so a description without operationIds has a complete CLI. From 591187559fa420d089fc2a10624b5fcb7a8dedad Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 18 Aug 2026 17:47:12 +0300 Subject: [PATCH 201/211] fix: make a cli operation named after a tag reachable The parser read a leading word as a group whenever it matched a tag slug, so an untagged operation of that name could not be run at all: the group branch took the word, and no group prefix exists for an untagged operation. Its name now wins over a group of the same slug. A tagged operation keeps yielding to group help, since ` ` still runs it. The generator warns once per run for either case and names the address that works, and the CLI guide states how the first word resolves. --- docs/@v2/guides/use-generated-client.md | 4 +++ .../src/emitters/__tests__/cli.test.ts | 32 +++++++++++++++++++ packages/client-generator/src/emitters/cli.ts | 30 ++++++++++++++++- .../src/emitters/runtime-sources.ts | 4 +-- .../src/runtime/__tests__/cli.test.ts | 18 +++++++++++ packages/client-generator/src/runtime/cli.ts | 7 +++- 6 files changed, 91 insertions(+), 4 deletions(-) diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index a3fdc20c14..d1bc97f3df 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -61,6 +61,10 @@ A tag adds a group, and the group organizes `--help`. This matters for an API with hundreds of operations: ` --help` lists the groups, and ` orders --help` lists the commands of one group. A group also addresses a command (` orders listOrders`), which is what you would type after browsing that group, but it is never required. Two commands cannot share a name: when a description declares the same `operationId` twice, the generator reports it and emits the second as `_2`. +The first word is a group when it matches a group slug, and a command name in every other case. +Because of this, an operation that carries a tag and is also named after a tag (`operationId: orders` in an API that has an `orders` tag) is available as ` orders`, and ` orders` shows the `orders` group. +An operation with no tag keeps the bare form, because a group cannot address it, and the group of that name then has no help page. +The generator reports both cases when it writes the CLI, so you can rename the operation or the tag. An operation with no `operationId` still gets a command. The generator derives the name from the method and the path (`GET /pets` becomes `getPets`, and `GET /pets/{id}` becomes `getPetsId`), so a description without operationIds has a complete CLI. diff --git a/packages/client-generator/src/emitters/__tests__/cli.test.ts b/packages/client-generator/src/emitters/__tests__/cli.test.ts index fcd22e268f..d96d68b8c5 100644 --- a/packages/client-generator/src/emitters/__tests__/cli.test.ts +++ b/packages/client-generator/src/emitters/__tests__/cli.test.ts @@ -1,3 +1,5 @@ +import { logger } from '@redocly/openapi-core'; + import type { ApiModel, SchemaModel } from '../../intermediate-representation/model.js'; import { commandData, renderCliModule, renderComposedCliEntry } from '../cli.js'; @@ -253,6 +255,36 @@ describe('renderCliModule', () => { 'use(zodValidation(process.argv.includes("--dry-run") ? { response: false } : {}));' ); }); + + /** `orders` is the slug of the `Orders` tag, so an operation of that name collides. */ + function modelWithOperationNamedOrders(tags: string[]): ApiModel { + const [service] = MODEL.services; + return { + ...MODEL, + services: [ + { + ...service, + operations: service.operations.map((op) => + op.name === 'getOrder' ? { ...op, name: 'orders', tags } : op + ), + }, + ], + }; + } + + it('warns when an operation is named after a tag, naming how it resolves', () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + + renderCliModule(modelWithOperationNamedOrders(['Reports']), options); + expect(warn.mock.lastCall?.[0]).toContain('orders (run it as "reports orders")'); + + renderCliModule(modelWithOperationNamedOrders([]), options); + expect(warn.mock.lastCall?.[0]).toContain('orders (keeps the bare word'); + + renderCliModule(MODEL, options); + expect(warn).toHaveBeenCalledTimes(2); + warn.mockRestore(); + }); }); describe('renderComposedCliEntry', () => { diff --git a/packages/client-generator/src/emitters/cli.ts b/packages/client-generator/src/emitters/cli.ts index 5ccc15b677..b00457565c 100644 --- a/packages/client-generator/src/emitters/cli.ts +++ b/packages/client-generator/src/emitters/cli.ts @@ -2,6 +2,8 @@ // `.cli.ts` — a shebang entry that embeds (inline) or imports (package) // the `runCli` engine and dispatches through the sibling generated client. +import { logger } from '@redocly/openapi-core'; + import { casing } from '../authoring/naming.js'; import type { ApiModel, @@ -9,7 +11,7 @@ import type { ParamModel, SchemaModel, } from '../intermediate-representation/model.js'; -import type { CliAuthScheme, CliCommand, CliFlag } from '../runtime/cli.js'; +import { groupSlug, type CliAuthScheme, type CliCommand, type CliFlag } from '../runtime/cli.js'; import { HEADER } from './emit-options.js'; import { embedCliRuntime } from './inline-runtime.js'; import { resolveOperationPagination, type PaginationConfig } from './pagination.js'; @@ -149,9 +151,35 @@ export function cliAuthSchemes(model: ApiModel): CliAuthScheme[] { })); } +/** How an operation named after a tag is reached — the two halves of `parseInvocation`. */ +function shadowedAddress(command: CliCommand): string { + return command.group === undefined + ? `${command.name} (keeps the bare word, so the "${command.name}" group has no help page)` + : `${command.name} (run it as "${groupSlug(command.group)} ${command.name}")`; +} + +/** + * A leading group name is read as the group, so an operation whose name is also a tag name + * resolves unusually: a tagged one loses the bare form and runs as ` `, + * and an untagged one keeps the bare form and hides that group's help. Nothing becomes + * unreachable either way, but only the description's author can rename a side of the + * collision, so say it once at generation time. + */ +function warnShadowedCommands(commands: CliCommand[]): void { + const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string))); + const shadowed = commands.filter((command) => slugs.has(command.name)); + if (shadowed.length === 0) return; + logger.warn( + `generate-client: cli reads a leading group name as the group, so ${shadowed.length} operation(s) named after a tag resolve unusually — rename the operation or the tag: ${shadowed + .map(shadowedAddress) + .join(', ')}.\n` + ); +} + /** The whole `.cli.ts` file. */ export function renderCliModule(model: ApiModel, options: CliModuleOptions): string { const commands = commandData(model, { pagination: options.pagination }); + warnShadowedCommands(commands); const schemes = cliAuthSchemes(model); const clientModule = `./${options.stem}.${options.importExt}`; const clientImports = ['client', 'configure', ...(options.zodSelected ? ['use'] : [])]; diff --git a/packages/client-generator/src/emitters/runtime-sources.ts b/packages/client-generator/src/emitters/runtime-sources.ts index 434539ba5f..1b9de73cf6 100644 --- a/packages/client-generator/src/emitters/runtime-sources.ts +++ b/packages/client-generator/src/emitters/runtime-sources.ts @@ -25,7 +25,7 @@ export const RUNTIME_SOURCES = { 'paginate.ts': "import type { OperationArgs } from './create-client.js';\nimport type { PaginationSpec, QueryValue, RequestOptions } from './types.js';\n\n/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `params` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nexport function resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `params[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nexport async function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.params?.[spec.param];\n while (true) {\n const params = { ...args.params };\n if (cursor !== undefined) params[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, params }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `params[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.params?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call(\n { ...args, params: { ...args.params, [spec.param]: position } },\n init\n );\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nexport async function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\nexport type LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nexport function linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nexport async function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let params = args.params;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, params }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n params = { ...args.params, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nexport async function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n", 'cli.ts': - "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n binName: string;\n /** Credential variable prefix. Defaults to `binName`, constant-cased — set it when the\n * displayed name and the credential family must differ (a composed multi-API binary). */\n envPrefix?: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\nexport type CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\nexport type CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\nexport type CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\nexport type CommandSource = {\n namespace?: string;\n commands: Array;\n /** Absent = inherit the first wired source's — a root `login` shares the binary's identity. */\n wiring?: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nexport function groupSlug(group: string): string {\n return group\n .toLowerCase()\n .split(/[^a-z0-9]+/)\n .filter(Boolean)\n .join('-');\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nexport function envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nexport async function runCli(sources: CommandSource[], argv: string[]): Promise;\nexport async function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // A source without wiring inherits the first wired one, so the documented root-source\n // shape `{ commands: [login] }` works: the login shares the composed binary's identity.\n const inherited = sources.find((source) => source.wiring !== undefined)?.wiring;\n const wiringOf = (source: CommandSource): CliWiring => source.wiring ?? (inherited as CliWiring);\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = wiringOf(sources[0]);\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.binName)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, wiringOf(source), argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, wiringOf(root as CommandSource), argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], binName: string): string[] {\n const lines = [`Usage: ${binName} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${binName} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n for (const line of renderHelp(\n commands,\n wiring.binName,\n wiring.schemes ?? [],\n prefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n // Redaction matches these against header VALUES — so basic auth must contribute the\n // form the wire actually carries (`Basic ${base64(user:pass)}`), not just the raw\n // password, which never appears in the encoded header.\n const basic = auth.basic as { username: string; password: string } | undefined;\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(basic ? [basic.password, btoa(`${basic.username}:${basic.password}`)] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n // An SSE method returns a lazy stream — drain the stubbed response so its fetch\n // actually runs and captures the request.\n if (command.sse) for await (const _event of result as AsyncIterable);\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", + "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n binName: string;\n /** Credential variable prefix. Defaults to `binName`, constant-cased — set it when the\n * displayed name and the credential family must differ (a composed multi-API binary). */\n envPrefix?: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\nexport type CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\nexport type CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\nexport type CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\nexport type CommandSource = {\n namespace?: string;\n commands: Array;\n /** Absent = inherit the first wired source's — a root `login` shares the binary's identity. */\n wiring?: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nexport function groupSlug(group: string): string {\n return group\n .toLowerCase()\n .split(/[^a-z0-9]+/)\n .filter(Boolean)\n .join('-');\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n // An untagged operation is only ever addressed by its bare name, so when that name is also\n // a group slug the name wins — reading it as the group would leave the command unreachable.\n // A tagged operation in the same position keeps yielding to group help: it is still\n // reachable as ` `.\n const untagged = commands.some((c) => c.group === undefined && c.name === argv[0]);\n let command: CliCommand | undefined;\n let rest: string[];\n if (!untagged && slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nexport function envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nexport async function runCli(sources: CommandSource[], argv: string[]): Promise;\nexport async function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // A source without wiring inherits the first wired one, so the documented root-source\n // shape `{ commands: [login] }` works: the login shares the composed binary's identity.\n const inherited = sources.find((source) => source.wiring !== undefined)?.wiring;\n const wiringOf = (source: CommandSource): CliWiring => source.wiring ?? (inherited as CliWiring);\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = wiringOf(sources[0]);\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.binName)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, wiringOf(source), argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, wiringOf(root as CommandSource), argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], binName: string): string[] {\n const lines = [`Usage: ${binName} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${binName} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n for (const line of renderHelp(\n commands,\n wiring.binName,\n wiring.schemes ?? [],\n prefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n // Redaction matches these against header VALUES — so basic auth must contribute the\n // form the wire actually carries (`Basic ${base64(user:pass)}`), not just the raw\n // password, which never appears in the encoded header.\n const basic = auth.basic as { username: string; password: string } | undefined;\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(basic ? [basic.password, btoa(`${basic.username}:${basic.password}`)] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n // An SSE method returns a lazy stream — drain the stubbed response so its fetch\n // actually runs and captures the request.\n if (command.sse) for await (const _event of result as AsyncIterable);\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", } as const; /** Inline-embed variants: imports dropped, `export` stripped outside the kept surface. */ @@ -55,7 +55,7 @@ export const RUNTIME_SOURCES_STRIPPED = { 'paginate.ts': "/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `params` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nfunction resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `params[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nasync function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.params?.[spec.param];\n while (true) {\n const params = { ...args.params };\n if (cursor !== undefined) params[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, params }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `params[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.params?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call(\n { ...args, params: { ...args.params, [spec.param]: position } },\n init\n );\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nasync function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\ntype LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nfunction linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nasync function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let params = args.params;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, params }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n params = { ...args.params, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nasync function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}", 'cli.ts': - "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\ntype CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\ntype CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\ntype CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\ntype CliWiring = {\n binName: string;\n /** Credential variable prefix. Defaults to `binName`, constant-cased — set it when the\n * displayed name and the credential family must differ (a composed multi-API binary). */\n envPrefix?: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\ntype CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\ntype CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\ntype CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\ntype CommandSource = {\n namespace?: string;\n commands: Array;\n /** Absent = inherit the first wired source's — a root `login` shares the binary's identity. */\n wiring?: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nfunction groupSlug(group: string): string {\n return group\n .toLowerCase()\n .split(/[^a-z0-9]+/)\n .filter(Boolean)\n .join('-');\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nfunction parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n let command: CliCommand | undefined;\n let rest: string[];\n if (slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nfunction envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nasync function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nasync function runCli(sources: CommandSource[], argv: string[]): Promise;\nasync function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // A source without wiring inherits the first wired one, so the documented root-source\n // shape `{ commands: [login] }` works: the login shares the composed binary's identity.\n const inherited = sources.find((source) => source.wiring !== undefined)?.wiring;\n const wiringOf = (source: CommandSource): CliWiring => source.wiring ?? (inherited as CliWiring);\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = wiringOf(sources[0]);\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.binName)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, wiringOf(source), argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, wiringOf(root as CommandSource), argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], binName: string): string[] {\n const lines = [`Usage: ${binName} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${binName} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n for (const line of renderHelp(\n commands,\n wiring.binName,\n wiring.schemes ?? [],\n prefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n // Redaction matches these against header VALUES — so basic auth must contribute the\n // form the wire actually carries (`Basic ${base64(user:pass)}`), not just the raw\n // password, which never appears in the encoded header.\n const basic = auth.basic as { username: string; password: string } | undefined;\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(basic ? [basic.password, btoa(`${basic.username}:${basic.password}`)] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n // An SSE method returns a lazy stream — drain the stubbed response so its fetch\n // actually runs and captures the request.\n if (command.sse) for await (const _event of result as AsyncIterable);\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}", + "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\ntype CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\ntype CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\ntype CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\ntype CliWiring = {\n binName: string;\n /** Credential variable prefix. Defaults to `binName`, constant-cased — set it when the\n * displayed name and the credential family must differ (a composed multi-API binary). */\n envPrefix?: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\ntype CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\ntype CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\ntype CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\ntype CommandSource = {\n namespace?: string;\n commands: Array;\n /** Absent = inherit the first wired source's — a root `login` shares the binary's identity. */\n wiring?: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nfunction groupSlug(group: string): string {\n return group\n .toLowerCase()\n .split(/[^a-z0-9]+/)\n .filter(Boolean)\n .join('-');\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nfunction parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n // An untagged operation is only ever addressed by its bare name, so when that name is also\n // a group slug the name wins — reading it as the group would leave the command unreachable.\n // A tagged operation in the same position keeps yielding to group help: it is still\n // reachable as ` `.\n const untagged = commands.some((c) => c.group === undefined && c.name === argv[0]);\n let command: CliCommand | undefined;\n let rest: string[];\n if (!untagged && slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nfunction envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nasync function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nasync function runCli(sources: CommandSource[], argv: string[]): Promise;\nasync function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // A source without wiring inherits the first wired one, so the documented root-source\n // shape `{ commands: [login] }` works: the login shares the composed binary's identity.\n const inherited = sources.find((source) => source.wiring !== undefined)?.wiring;\n const wiringOf = (source: CommandSource): CliWiring => source.wiring ?? (inherited as CliWiring);\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = wiringOf(sources[0]);\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.binName)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, wiringOf(source), argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, wiringOf(root as CommandSource), argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], binName: string): string[] {\n const lines = [`Usage: ${binName} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${binName} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n for (const line of renderHelp(\n commands,\n wiring.binName,\n wiring.schemes ?? [],\n prefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n // Redaction matches these against header VALUES — so basic auth must contribute the\n // form the wire actually carries (`Basic ${base64(user:pass)}`), not just the raw\n // password, which never appears in the encoded header.\n const basic = auth.basic as { username: string; password: string } | undefined;\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(basic ? [basic.password, btoa(`${basic.username}:${basic.password}`)] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n // An SSE method returns a lazy stream — drain the stubbed response so its fetch\n // actually runs and captures the request.\n if (command.sse) for await (const _event of result as AsyncIterable);\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}", } as const; export type RuntimeModuleName = keyof typeof RUNTIME_SOURCES; diff --git a/packages/client-generator/src/runtime/__tests__/cli.test.ts b/packages/client-generator/src/runtime/__tests__/cli.test.ts index 7c407c3bbe..de3dc48084 100644 --- a/packages/client-generator/src/runtime/__tests__/cli.test.ts +++ b/packages/client-generator/src/runtime/__tests__/cli.test.ts @@ -70,6 +70,24 @@ describe('parseInvocation', () => { expect(parseInvocation(COMMANDS, ['ping'])).toMatchObject({ kind: 'run', command: PING }); }); + it('a name that is also a group: the untagged command wins, the tagged one keeps group help', () => { + const untagged: CliCommand = { ...PING, name: 'orders' }; + expect(parseInvocation([...COMMANDS, untagged], ['orders'])).toMatchObject({ + kind: 'run', + command: untagged, + }); + // Tagged elsewhere, the group still owns the bare word — `misc orders` runs the command. + const tagged: CliCommand = { ...PING, group: 'misc', name: 'orders' }; + expect(parseInvocation([...COMMANDS, tagged], ['orders'])).toMatchObject({ + kind: 'help', + topic: 'orders', + }); + expect(parseInvocation([...COMMANDS, tagged], ['misc', 'orders'])).toMatchObject({ + kind: 'run', + command: tagged, + }); + }); + it('extracts global flags and leaves the body source raw', () => { const parsed = parseInvocation(COMMANDS, [ 'orders', diff --git a/packages/client-generator/src/runtime/cli.ts b/packages/client-generator/src/runtime/cli.ts index 88f2a228d1..08fac9a2fd 100644 --- a/packages/client-generator/src/runtime/cli.ts +++ b/packages/client-generator/src/runtime/cli.ts @@ -190,9 +190,14 @@ export function parseInvocation(commands: CliCommand[], argv: string[]): CliInvo } const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string))); + // An untagged operation is only ever addressed by its bare name, so when that name is also + // a group slug the name wins — reading it as the group would leave the command unreachable. + // A tagged operation in the same position keeps yielding to group help: it is still + // reachable as ` `. + const untagged = commands.some((c) => c.group === undefined && c.name === argv[0]); let command: CliCommand | undefined; let rest: string[]; - if (slugs.has(argv[0])) { + if (!untagged && slugs.has(argv[0])) { if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] }; command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]); if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` }; From b9457305d7e59d6744e492a98afc53418ca54877 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Tue, 18 Aug 2026 18:08:35 +0300 Subject: [PATCH 202/211] fix: resolve a nested discriminated union in pydantic models, drop a stale import Under `models: pydantic` the decoder hands a whole object tree to `model_validate`, so a union nested in a model was resolved by pydantic's shape matching and never reached the discriminator table that dataclass mode walks: an item tagged `dog` could hydrate as `Cat`. Such a union now carries its discriminator into the annotation, and each member pins its mapped value as a `Literal`, which is what pydantic needs to resolve it at any depth. Also: a package-mode client no longer imports `TokenProvider`. It typed the credential setters that this branch removed, and an unused type import fails a consumer's `noUnusedLocals` build. Docs: the migration table no longer points at the removed setters, and its pagination row keeps Vale happy. --- .../@v2/guides/migrate-to-generated-client.md | 22 ++--- docs/@v2/guides/use-generated-client.md | 3 + .../skills/python-generator/SKILL.md | 10 +++ .../runtime/python/_decode.py | 4 + .../__tests__/client-assembly.test.ts | 6 +- .../src/emitters/client-assembly.ts | 8 +- .../src/emitters/python-runtime-sources.ts | 2 +- .../src/generators/__tests__/python.test.ts | 46 ++++++++++ .../src/generators/python/AGENTS.md | 10 +++ .../src/generators/python/index.ts | 89 +++++++++++++++++-- tests/e2e/generate-client/python.test.ts | 27 ++++++ 11 files changed, 199 insertions(+), 28 deletions(-) diff --git a/docs/@v2/guides/migrate-to-generated-client.md b/docs/@v2/guides/migrate-to-generated-client.md index 5260a557b8..7a389bece6 100644 --- a/docs/@v2/guides/migrate-to-generated-client.md +++ b/docs/@v2/guides/migrate-to-generated-client.md @@ -37,17 +37,17 @@ A reviewer then sees what changed in the API when you regenerate, and the build The pieces of a hand-written client have direct equivalents: -| What you have now | What replaces it | -| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| A types file, generated or hand-written | The types in the generated client. Every operation carries its own request and response types. | -| A `fetch` wrapper with a base URL | `configure({ serverUrl })`, or the `servers` entry of the description. | -| Auth headers added by hand | `configure({ auth: … })`, or the generated setter for each scheme the description declares. See [Authentication](./use-generated-client.md#authentication). | -| A retry helper | `configure({ retry: { retries: 3 } })`. See [Retries](./use-generated-client.md#retries). | -| A hand-rolled pagination loop | Declared [pagination](./use-generated-client.md#pagination), then `.pages()` and `.items()` iterators. | -| Interceptors for logs, traces, or headers | [Middleware](./use-generated-client.md#middleware), which sees each operation's id and tags as literal types. | -| An existing configured request library | `configure({ fetch })`. See [The HTTP layer](./use-generated-client.md#the-http-layer). | -| Response shapes checked by hand | The [`zod` generator](./use-generated-client.md#runtime-validation) and its `zodValidation()` middleware. | -| Hand-written API mocks in tests | The [`mock` generator](./use-generated-client.md#generators): MSW handlers and typed data factories. | +| What you have now | What replaces it | +| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| A types file, generated or hand-written | The types in the generated client. Every operation carries its own request and response types. | +| A `fetch` wrapper with a base URL | `configure({ serverUrl })`, or the `servers` entry of the description. | +| Auth headers added by hand | `configure({ auth: … })`, or `client.auth.bearer(…)` on one instance. See [Authentication](./use-generated-client.md#authentication). | +| A retry helper | `configure({ retry: { retries: 3 } })`. See [Retries](./use-generated-client.md#retries). | +| A hand-rolled pagination loop | Declared [pagination](./use-generated-client.md#pagination) with the `.pages()` and `.items()` iterators. | +| Interceptors for logs, traces, or headers | [Middleware](./use-generated-client.md#middleware), which sees each operation's id and tags as literal types. | +| An existing configured request library | `configure({ fetch })`. See [The HTTP layer](./use-generated-client.md#the-http-layer). | +| Response shapes checked by hand | The [`zod` generator](./use-generated-client.md#runtime-validation) and its `zodValidation()` middleware. | +| Hand-written API mocks in tests | The [`mock` generator](./use-generated-client.md#generators): MSW handlers and typed data factories. | Two of those replace whole files rather than lines. Pagination loops and mock fixtures are usually the largest deletions in a migration of this kind. diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index d1bc97f3df..07e224728d 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -264,6 +264,9 @@ Every class then extends `BaseModel`, and a wire name that is not a legal Python The call sites do not change: the same class names, the same field names, the same client. Pydantic then validates each response as the SDK decodes it, so a response that does not match the description raises `ValidationError` instead of passing through. This mode needs `pydantic` next to `httpx`, and the header of the generated file says so. +A discriminated union keeps its discriminator in both modes, and each member declares its own value as a `Literal`. +For this to work, every member schema must declare the discriminator property. +When a member omits it, pydantic matches the members of a nested union by shape, which can select the wrong one. ```php require 'client.php'; 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 e6ad40f3db..735a81ace4 100644 --- a/packages/client-generator/eject-assets/skills/python-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/python-generator/SKILL.md @@ -32,6 +32,16 @@ One self-contained `.py`: typed dataclass models, a sync `Client` and an a mapping. Everything else is unchanged: the same class names, the same field names, the same `Optional[T] = None`, the same enums and union aliases, the same client and runtime. Switching modes must not change a call site. +- **A discriminated union carries its discriminator into the pydantic annotation.** The + decoder hands a whole object tree to `model_validate`, so a union nested in a model is + resolved by pydantic and never reaches the `DISCRIMINATORS` table that dataclass mode + walks. Pydantic resolves it correctly from `Annotated[Union[...], Field(discriminator=…)]`, + which it accepts only when every member types that property as a `Literal` — and the + mapping already pins one value per member, so the members get `Literal["cat"]`. Such a + union registers no table entry: pydantic owns it at every depth, and the `Literal` makes + the decoder's member probe exact. A union whose members never declare the property keeps + the plain `Union` and the table entry, and pydantic then matches nested members its own + way — the description is what has to change there. - **One runtime serves both model modes.** `_decode.py` dispatches on the target: a class with `model_validate` is validated by pydantic, a dataclass is hydrated reflectively, and `encode` mirrors that with `model_dump(by_alias=True, exclude_none=True, mode="json")`. diff --git a/packages/client-generator/runtime/python/_decode.py b/packages/client-generator/runtime/python/_decode.py index 04abc99bb2..0d17ddc971 100644 --- a/packages/client-generator/runtime/python/_decode.py +++ b/packages/client-generator/runtime/python/_decode.py @@ -24,6 +24,10 @@ def decode(type_: Any, data: Any): mismatched shapes pass through unchanged (the server is the source of truth).""" if data is None or type_ is Any or type_ is None: return data + # `Annotated[Union[...], Field(discriminator=...)]`: pydantic reads that annotation on a + # model's own field, so here only the union underneath matters. + if hasattr(type_, "__metadata__"): + type_ = get_args(type_)[0] origin = get_origin(type_) if origin is typing.Union: discriminator = DISCRIMINATORS.get(type_) 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 9fe36c4e0e..538f21e043 100644 --- a/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts +++ b/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts @@ -91,8 +91,10 @@ describe('emitClientSingleFile (package arm)', () => { const output = emit(CAFE, { serverUrl: 'https://x' }); it('imports from the package instead of inlining the runtime template', () => { + // Only the names the file references: `TokenProvider` typed the removed apiKey + // setters, and an unused type import fails a consumer's `noUnusedLocals` build. expect(output).toContain( - "import { createClient, type EnvelopeResult, type OperationDescriptor, type RequestOptions, type SseOptions, type TokenProvider } from '@redocly/client-generator';" + "import { createClient, type EnvelopeResult, type OperationDescriptor, type RequestOptions, type SseOptions } from '@redocly/client-generator';" ); expect(output).not.toContain('__send'); expect(output).not.toContain('__buildUrl'); @@ -273,7 +275,7 @@ describe('emitClientSingleFile (package arm)', () => { expect(out).not.toContain('=> client.getOrder('); // No flat sugar → the per-call option types are not imported (only re-exported). expect(out).toContain( - "import { createClient, type OperationDescriptor, type TokenProvider } from '@redocly/client-generator';" + "import { createClient, type OperationDescriptor } from '@redocly/client-generator';" ); }); diff --git a/packages/client-generator/src/emitters/client-assembly.ts b/packages/client-generator/src/emitters/client-assembly.ts index 44b836ee8d..263fe28ae2 100644 --- a/packages/client-generator/src/emitters/client-assembly.ts +++ b/packages/client-generator/src/emitters/client-assembly.ts @@ -76,9 +76,6 @@ function emitClient( const flat = ctx.argsStyle === 'flat'; const hasSse = ops.some(isSseOp); const hasRegular = ops.some((op) => !isSseOp(op)); - const apiKeySchemes = model.securitySchemes.filter( - (s) => s.kind === 'apiKeyHeader' || s.kind === 'apiKeyQuery' || s.kind === 'apiKeyCookie' - ); const wiring = ops.length > 0 @@ -106,7 +103,6 @@ function emitClient( hasFlatSse: hasSse && flat, hasFlatRegular: hasRegular && flat, hasRegular, - hasApiKey: apiKeySchemes.length > 0, }); const schemaSection = [ renderTypeAliases(model.schemas, ctx.dateType), @@ -176,7 +172,7 @@ function schemaLinks(model: ApiModel, ctx: EmitContext, specifier: string): stri function importLine( options: EmitOptions, ctx: EmitContext, - refs: { hasFlatSse: boolean; hasFlatRegular: boolean; hasRegular: boolean; hasApiKey: boolean } + refs: { hasFlatSse: boolean; hasFlatRegular: boolean; hasRegular: boolean } ): string { const values = ['createClient', ...(options.setup ? ['mergeSetup'] : [])]; const types = [ @@ -190,8 +186,6 @@ function importLine( // (an SSE-only spec would otherwise import it unused and fail noUnusedLocals). ...(ctx.errorMode === 'result' && refs.hasRegular ? ['Result'] : []), ...(refs.hasFlatSse ? ['SseOptions'] : []), - // The apiKey sugar closures take a `TokenProvider`. - ...(refs.hasApiKey ? ['TokenProvider'] : []), ].sort(); const names = [...values, ...types.map((t) => `type ${t}`)].join(', '); return `import { ${names} } from '${PACKAGE_SPECIFIER}';`; diff --git a/packages/client-generator/src/emitters/python-runtime-sources.ts b/packages/client-generator/src/emitters/python-runtime-sources.ts index c83f0dae73..905177aa74 100644 --- a/packages/client-generator/src/emitters/python-runtime-sources.ts +++ b/packages/client-generator/src/emitters/python-runtime-sources.ts @@ -7,7 +7,7 @@ export const PYTHON_RUNTIME_SOURCES = { '_url.py': '# URL assembly for generated Python clients — path-parameter substitution with\n# percent-encoding, mirroring the TypeScript runtime\'s url.ts semantics.\nfrom __future__ import annotations\n\nfrom typing import Any, Dict\nfrom urllib.parse import quote\n\n\ndef build_url(server_url: str, path: str, path_params: Dict[str, Any]) -> str:\n filled = path\n for name, value in path_params.items():\n filled = filled.replace("{" + name + "}", quote(str(value), safe=""))\n return server_url.rstrip("/") + filled\n', '_decode.py': - '# Reflective JSON <-> model conversion for generated Python clients. Models are\n# plain dataclasses by default, or pydantic BaseModels under `models: pydantic`;\n# one decoder serves both. For a dataclass it hydrates parsed JSON reflectively,\n# honoring each class\'s `_field_map` (python name -> wire name) and the typing\n# constructs the generator emits: Optional/Union, List, Dict, Enum, Literal, Any.\n# For a pydantic model it defers to pydantic, which already knows the aliases.\n# encode() mirrors whichever it was given back to wire shape.\nfrom __future__ import annotations\n\nimport dataclasses\nimport typing\nfrom datetime import date, datetime\nfrom enum import Enum\nfrom typing import Any, Dict, Tuple, get_args, get_origin, get_type_hints\n\n# Discriminated unions: resolved Union annotation -> (wire property, {value: class}).\n# The generated module registers its unions here; decode() dispatches through it\n# before falling back to trying members in order.\nDISCRIMINATORS: Dict[Any, Tuple[str, Dict[str, Any]]] = {}\n\n\ndef decode(type_: Any, data: Any):\n """Best-effort hydration: wire data -> the annotated Python shape. Unknown or\n mismatched shapes pass through unchanged (the server is the source of truth)."""\n if data is None or type_ is Any or type_ is None:\n return data\n origin = get_origin(type_)\n if origin is typing.Union:\n discriminator = DISCRIMINATORS.get(type_)\n if discriminator is not None and isinstance(data, dict):\n wire_property, mapping = discriminator\n target = mapping.get(data.get(wire_property))\n if target is not None:\n try:\n return decode(target, data)\n except (TypeError, ValueError, KeyError):\n pass\n for member in get_args(type_):\n if member is type(None):\n continue\n try:\n return decode(member, data)\n except (TypeError, ValueError, KeyError):\n continue\n return data\n if origin is list:\n (item_type,) = get_args(type_) or (Any,)\n return [decode(item_type, item) for item in data]\n if origin is dict:\n args = get_args(type_)\n value_type = args[1] if len(args) == 2 else Any\n return {key: decode(value_type, value) for key, value in data.items()}\n if origin is typing.Literal:\n return data\n if isinstance(type_, type) and issubclass(type_, Enum):\n return type_(data)\n # `dateType: Date` annotates date/date-time fields as datetime objects; a value that\n # doesn\'t parse passes through unchanged (the server is the source of truth).\n if type_ is datetime or type_ is date:\n if not isinstance(data, str):\n return data\n try:\n # `datetime` accepts a bare date too; `date` rejects a timestamp, so trim it.\n return (\n datetime.fromisoformat(data)\n if type_ is datetime\n else date.fromisoformat(data[:10])\n )\n except ValueError:\n return data\n # A pydantic model validates itself, aliases included. `ValidationError`\n # subclasses `ValueError`, so union member probing above still works.\n if isinstance(type_, type) and hasattr(type_, "model_validate"):\n return type_.model_validate(data)\n if dataclasses.is_dataclass(type_):\n hints = get_type_hints(type_)\n field_map = getattr(type_, "_field_map", {})\n kwargs = {}\n for field in dataclasses.fields(type_):\n wire = field_map.get(field.name, field.name)\n if isinstance(data, dict) and wire in data:\n kwargs[field.name] = decode(hints.get(field.name, Any), data[wire])\n return type_(**kwargs)\n return data\n\n\ndef encode(value: Any):\n """Python shape -> wire (JSON) shape; inverse of decode for request bodies."""\n # `mode="json"` resolves datetimes and enums the same way the branches below do,\n # and `exclude_none` matches the dataclass path: an unset optional is not sent.\n if hasattr(value, "model_dump") and not isinstance(value, type):\n return value.model_dump(by_alias=True, exclude_none=True, mode="json")\n if dataclasses.is_dataclass(value) and not isinstance(value, type):\n field_map = getattr(type(value), "_field_map", {})\n out = {}\n for field in dataclasses.fields(value):\n item = getattr(value, field.name)\n if item is None:\n continue\n out[field_map.get(field.name, field.name)] = encode(item)\n return out\n if isinstance(value, Enum):\n return value.value\n # A date-only value must not gain a time component on the way out.\n if isinstance(value, datetime):\n return value.isoformat()\n if isinstance(value, date):\n return value.isoformat()\n if isinstance(value, list):\n return [encode(item) for item in value]\n if isinstance(value, dict):\n return {key: encode(item) for key, item in value.items()}\n return value\n', + '# Reflective JSON <-> model conversion for generated Python clients. Models are\n# plain dataclasses by default, or pydantic BaseModels under `models: pydantic`;\n# one decoder serves both. For a dataclass it hydrates parsed JSON reflectively,\n# honoring each class\'s `_field_map` (python name -> wire name) and the typing\n# constructs the generator emits: Optional/Union, List, Dict, Enum, Literal, Any.\n# For a pydantic model it defers to pydantic, which already knows the aliases.\n# encode() mirrors whichever it was given back to wire shape.\nfrom __future__ import annotations\n\nimport dataclasses\nimport typing\nfrom datetime import date, datetime\nfrom enum import Enum\nfrom typing import Any, Dict, Tuple, get_args, get_origin, get_type_hints\n\n# Discriminated unions: resolved Union annotation -> (wire property, {value: class}).\n# The generated module registers its unions here; decode() dispatches through it\n# before falling back to trying members in order.\nDISCRIMINATORS: Dict[Any, Tuple[str, Dict[str, Any]]] = {}\n\n\ndef decode(type_: Any, data: Any):\n """Best-effort hydration: wire data -> the annotated Python shape. Unknown or\n mismatched shapes pass through unchanged (the server is the source of truth)."""\n if data is None or type_ is Any or type_ is None:\n return data\n # `Annotated[Union[...], Field(discriminator=...)]`: pydantic reads that annotation on a\n # model\'s own field, so here only the union underneath matters.\n if hasattr(type_, "__metadata__"):\n type_ = get_args(type_)[0]\n origin = get_origin(type_)\n if origin is typing.Union:\n discriminator = DISCRIMINATORS.get(type_)\n if discriminator is not None and isinstance(data, dict):\n wire_property, mapping = discriminator\n target = mapping.get(data.get(wire_property))\n if target is not None:\n try:\n return decode(target, data)\n except (TypeError, ValueError, KeyError):\n pass\n for member in get_args(type_):\n if member is type(None):\n continue\n try:\n return decode(member, data)\n except (TypeError, ValueError, KeyError):\n continue\n return data\n if origin is list:\n (item_type,) = get_args(type_) or (Any,)\n return [decode(item_type, item) for item in data]\n if origin is dict:\n args = get_args(type_)\n value_type = args[1] if len(args) == 2 else Any\n return {key: decode(value_type, value) for key, value in data.items()}\n if origin is typing.Literal:\n return data\n if isinstance(type_, type) and issubclass(type_, Enum):\n return type_(data)\n # `dateType: Date` annotates date/date-time fields as datetime objects; a value that\n # doesn\'t parse passes through unchanged (the server is the source of truth).\n if type_ is datetime or type_ is date:\n if not isinstance(data, str):\n return data\n try:\n # `datetime` accepts a bare date too; `date` rejects a timestamp, so trim it.\n return (\n datetime.fromisoformat(data)\n if type_ is datetime\n else date.fromisoformat(data[:10])\n )\n except ValueError:\n return data\n # A pydantic model validates itself, aliases included. `ValidationError`\n # subclasses `ValueError`, so union member probing above still works.\n if isinstance(type_, type) and hasattr(type_, "model_validate"):\n return type_.model_validate(data)\n if dataclasses.is_dataclass(type_):\n hints = get_type_hints(type_)\n field_map = getattr(type_, "_field_map", {})\n kwargs = {}\n for field in dataclasses.fields(type_):\n wire = field_map.get(field.name, field.name)\n if isinstance(data, dict) and wire in data:\n kwargs[field.name] = decode(hints.get(field.name, Any), data[wire])\n return type_(**kwargs)\n return data\n\n\ndef encode(value: Any):\n """Python shape -> wire (JSON) shape; inverse of decode for request bodies."""\n # `mode="json"` resolves datetimes and enums the same way the branches below do,\n # and `exclude_none` matches the dataclass path: an unset optional is not sent.\n if hasattr(value, "model_dump") and not isinstance(value, type):\n return value.model_dump(by_alias=True, exclude_none=True, mode="json")\n if dataclasses.is_dataclass(value) and not isinstance(value, type):\n field_map = getattr(type(value), "_field_map", {})\n out = {}\n for field in dataclasses.fields(value):\n item = getattr(value, field.name)\n if item is None:\n continue\n out[field_map.get(field.name, field.name)] = encode(item)\n return out\n if isinstance(value, Enum):\n return value.value\n # A date-only value must not gain a time component on the way out.\n if isinstance(value, datetime):\n return value.isoformat()\n if isinstance(value, date):\n return value.isoformat()\n if isinstance(value, list):\n return [encode(item) for item in value]\n if isinstance(value, dict):\n return {key: encode(item) for key, item in value.items()}\n return value\n', '_send.py': '# The request core for generated Python clients — mirror of the TypeScript\n# runtime\'s send.ts: default + config + per-call headers, on_request middleware\n# BEFORE serialization (mutations are sent), the retry loop (idempotent-methods\n# default, Idempotency-Key opt-in makes POST/PATCH safe, Retry-After honored,\n# exponential backoff with full jitter, a fresh timeout budget per attempt), and\n# the reverse on_response onion.\nfrom __future__ import annotations\n\nimport asyncio\nimport random\nimport time\nimport uuid\nfrom dataclasses import dataclass\nfrom typing import Any, Dict, Generic, List, Optional, Tuple, TypeVar\n\nimport httpx\n\nfrom ._errors import ApiTimeoutError\n\nT = TypeVar("T")\n\n\n@dataclass\nclass Envelope(Generic[T]):\n """A *_with_headers() result: decoded body + coerced declared headers + raw response."""\n\n data: T\n headers: Dict[str, Any]\n response: httpx.Response\n\n\ndef read_envelope_headers(\n response: httpx.Response, specs: List[Tuple[str, str, str]]\n) -> Dict[str, Any]:\n """Coerce declared response headers per (name, key, type) specs; absent/unparsable omitted."""\n headers: Dict[str, Any] = {}\n for name, key, type_ in specs:\n raw = response.headers.get(name)\n if raw is None:\n continue\n if type_ in ("integer", "number"):\n try:\n headers[key] = int(raw) if type_ == "integer" else float(raw)\n except ValueError:\n pass\n elif type_ == "boolean":\n lower = raw.strip().lower()\n if lower in ("true", "false"):\n headers[key] = lower == "true"\n else:\n headers[key] = raw\n return headers\n\n\n_IDEMPOTENT_METHODS = {"GET", "HEAD", "PUT", "DELETE", "OPTIONS"}\n_TRANSIENT_STATUS = {408, 429, 500, 502, 503, 504}\n\n\ndef _default_retry_on(method: str, headers: Dict[str, str], response: Optional[httpx.Response]) -> bool:\n safe = method.upper() in _IDEMPOTENT_METHODS or "Idempotency-Key" in headers\n if not safe:\n return False\n return response is None or response.status_code in _TRANSIENT_STATUS\n\n\ndef _retry_delay(retry: Dict[str, Any], attempt: int, retry_after: Optional[str]) -> float:\n if retry_after:\n try:\n return float(retry_after)\n except ValueError:\n pass # HTTP-date form: fall through to backoff\n base = float(retry.get("retry_delay", 1.0))\n raw = base if retry.get("retry_strategy") == "fixed" else base * (2 ** (attempt - 1))\n return random.uniform(0, raw) if retry.get("jitter", True) is not False else raw\n\n\ndef send(\n client: httpx.Client,\n config: Dict[str, Any],\n op: Dict[str, Any],\n url: str,\n *,\n method: str,\n headers: Optional[Dict[str, str]] = None,\n params: Optional[Dict[str, Any]] = None,\n json_body: Any = None,\n content: Any = None,\n data: Any = None,\n files: Any = None,\n timeout: Optional[float] = None,\n idempotency_key: Any = None,\n retry: Optional[Dict[str, Any]] = None,\n) -> httpx.Response:\n merged_retry: Dict[str, Any] = {**(config.get("retry") or {}), **(retry or {})}\n effective_timeout = timeout if timeout is not None else config.get("timeout")\n merged_headers: Dict[str, str] = {**(config.get("headers") or {}), **(headers or {})}\n\n # One stable key per LOGICAL call — set before the retry loop so every\n # attempt re-sends the same key; a caller-provided header always wins.\n key = idempotency_key if idempotency_key is not None else config.get("idempotency_key")\n if (\n key not in (None, False)\n and method.upper() in ("POST", "PATCH")\n and "Idempotency-Key" not in merged_headers\n ):\n merged_headers["Idempotency-Key"] = (\n key if isinstance(key, str) else key() if callable(key) else str(uuid.uuid4())\n )\n\n context = {\n "url": url,\n "method": method.upper(),\n "headers": merged_headers,\n "body": json_body,\n "operation": op,\n }\n middleware: List[Any] = config.get("middleware") or []\n for mw in middleware:\n on_request = getattr(mw, "on_request", None) or (mw.get("on_request") if isinstance(mw, dict) else None)\n if on_request:\n on_request(context)\n\n max_attempts = 1 + int(merged_retry.get("retries", 0))\n retry_on = merged_retry.get("retry_on") or (\n lambda ctx: _default_retry_on(context["method"], context["headers"], ctx.get("response"))\n )\n\n attempt = 0\n while True:\n attempt += 1\n try:\n response = client.request(\n context["method"],\n context["url"],\n headers=context["headers"],\n params=params,\n json=context["body"] if content is None and files is None and data is None else None,\n content=content,\n data=data,\n files=files,\n timeout=effective_timeout if effective_timeout is not None else httpx.USE_CLIENT_DEFAULT,\n )\n except httpx.TimeoutException:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n time.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise ApiTimeoutError(op.get("id", "?"), float(effective_timeout or 0), attempt) from None\n except httpx.TransportError:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n time.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise\n\n for mw in reversed(middleware):\n on_response = getattr(mw, "on_response", None) or (mw.get("on_response") if isinstance(mw, dict) else None)\n if on_response:\n replaced = on_response(response, context)\n if replaced is not None:\n response = replaced\n\n if (\n not response.is_success\n and attempt < max_attempts\n and retry_on({"attempt": attempt, "response": response})\n ):\n time.sleep(_retry_delay(merged_retry, attempt, response.headers.get("retry-after")))\n continue\n return response\n\n\nasync def send_async(\n client: httpx.AsyncClient,\n config: Dict[str, Any],\n op: Dict[str, Any],\n url: str,\n *,\n method: str,\n headers: Optional[Dict[str, str]] = None,\n params: Optional[Dict[str, Any]] = None,\n json_body: Any = None,\n content: Any = None,\n data: Any = None,\n files: Any = None,\n timeout: Optional[float] = None,\n idempotency_key: Any = None,\n retry: Optional[Dict[str, Any]] = None,\n) -> httpx.Response:\n """The async mirror of send() — same retry/timeout/idempotency semantics."""\n merged_retry: Dict[str, Any] = {**(config.get("retry") or {}), **(retry or {})}\n effective_timeout = timeout if timeout is not None else config.get("timeout")\n merged_headers: Dict[str, str] = {**(config.get("headers") or {}), **(headers or {})}\n key = idempotency_key if idempotency_key is not None else config.get("idempotency_key")\n if (\n key not in (None, False)\n and method.upper() in ("POST", "PATCH")\n and "Idempotency-Key" not in merged_headers\n ):\n merged_headers["Idempotency-Key"] = (\n key if isinstance(key, str) else key() if callable(key) else str(uuid.uuid4())\n )\n context = {\n "url": url,\n "method": method.upper(),\n "headers": merged_headers,\n "body": json_body,\n "operation": op,\n }\n middleware: List[Any] = config.get("middleware") or []\n for mw in middleware:\n on_request = getattr(mw, "on_request", None) or (mw.get("on_request") if isinstance(mw, dict) else None)\n if on_request:\n on_request(context)\n max_attempts = 1 + int(merged_retry.get("retries", 0))\n retry_on = merged_retry.get("retry_on") or (\n lambda ctx: _default_retry_on(context["method"], context["headers"], ctx.get("response"))\n )\n attempt = 0\n while True:\n attempt += 1\n try:\n response = await client.request(\n context["method"],\n context["url"],\n headers=context["headers"],\n params=params,\n json=context["body"] if content is None and files is None and data is None else None,\n content=content,\n data=data,\n files=files,\n timeout=effective_timeout if effective_timeout is not None else httpx.USE_CLIENT_DEFAULT,\n )\n except httpx.TimeoutException:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n await asyncio.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise ApiTimeoutError(op.get("id", "?"), float(effective_timeout or 0), attempt) from None\n except httpx.TransportError:\n if attempt < max_attempts and retry_on({"attempt": attempt, "response": None}):\n await asyncio.sleep(_retry_delay(merged_retry, attempt, None))\n continue\n raise\n for mw in reversed(middleware):\n on_response = getattr(mw, "on_response", None) or (mw.get("on_response") if isinstance(mw, dict) else None)\n if on_response:\n replaced = on_response(response, context)\n if replaced is not None:\n response = replaced\n if (\n not response.is_success\n and attempt < max_attempts\n and retry_on({"attempt": attempt, "response": response})\n ):\n await asyncio.sleep(_retry_delay(merged_retry, attempt, response.headers.get("retry-after")))\n continue\n return response\n', '_paginate.py': diff --git a/packages/client-generator/src/generators/__tests__/python.test.ts b/packages/client-generator/src/generators/__tests__/python.test.ts index 365b4f5eb8..18b6750a8b 100644 --- a/packages/client-generator/src/generators/__tests__/python.test.ts +++ b/packages/client-generator/src/generators/__tests__/python.test.ts @@ -143,6 +143,52 @@ describe('renderPythonModels', () => { expectCompiles(out); }); + /** Cat/Dog under a `petType` discriminator; `declares` controls whether they declare it. */ + function petUnion(declares: boolean) { + const member = { + kind: 'object' as const, + properties: declares ? [{ name: 'petType', schema: STRING, required: true }] : [], + }; + return { + Cat: member, + Dog: member, + Pet: { + kind: 'union' as const, + members: [ + { kind: 'ref' as const, name: 'Cat' }, + { kind: 'ref' as const, name: 'Dog' }, + ], + discriminator: { + propertyName: 'petType', + mapping: [ + { value: 'cat', schemaName: 'Cat' }, + { value: 'dog', schemaName: 'Dog' }, + ], + }, + }, + }; + } + + it('pins the discriminator as a Literal so pydantic resolves a nested union', () => { + const out = renderPythonModels(model(petUnion(true)), 'string', 'pydantic'); + expect(out).toContain('pet_type: Literal["cat"] = Field(alias="petType")'); + expect(out).toContain('pet_type: Literal["dog"] = Field(alias="petType")'); + expect(out).toContain('Pet = Annotated[Union[Cat, Dog], Field(discriminator="pet_type")]'); + expect(out).toContain('Annotated'); + expectCompiles(out); + }); + + it('leaves the union plain when its members do not declare the discriminator', () => { + const out = renderPythonModels(model(petUnion(false)), 'string', 'pydantic'); + expect(out).toContain('Pet = Union[Cat, Dog]'); + expect(out).not.toContain('Annotated'); + // Dataclass mode never annotates: it walks the fields and reads the table itself. + const dataclasses = renderPythonModels(model(petUnion(true)), 'string', 'dataclass'); + expect(dataclasses).toContain('Pet = Union[Cat, Dog]'); + expect(dataclasses).toContain('pet_type: str'); + expectCompiles(out); + }); + it('sanitizes reserved-word field names and records the wire mapping', () => { const out = renderPythonModels( model({ diff --git a/packages/client-generator/src/generators/python/AGENTS.md b/packages/client-generator/src/generators/python/AGENTS.md index 2aa1e54ec4..0f7b0a0cb8 100644 --- a/packages/client-generator/src/generators/python/AGENTS.md +++ b/packages/client-generator/src/generators/python/AGENTS.md @@ -31,6 +31,16 @@ One self-contained `.py`: typed dataclass models, a sync `Client` and an a mapping. Everything else is unchanged: the same class names, the same field names, the same `Optional[T] = None`, the same enums and union aliases, the same client and runtime. Switching modes must not change a call site. +- **A discriminated union carries its discriminator into the pydantic annotation.** The + decoder hands a whole object tree to `model_validate`, so a union nested in a model is + resolved by pydantic and never reaches the `DISCRIMINATORS` table that dataclass mode + walks. Pydantic resolves it correctly from `Annotated[Union[...], Field(discriminator=…)]`, + which it accepts only when every member types that property as a `Literal` — and the + mapping already pins one value per member, so the members get `Literal["cat"]`. Such a + union registers no table entry: pydantic owns it at every depth, and the `Literal` makes + the decoder's member probe exact. A union whose members never declare the property keeps + the plain `Union` and the table entry, and pydantic then matches nested members its own + way — the description is what has to change there. - **One runtime serves both model modes.** `_decode.py` dispatches on the target: a class with `model_validate` is validated by pydantic, a dataclass is hydrated reflectively, and `encode` mirrors that with `model_dump(by_alias=True, exclude_none=True, mode="json")`. diff --git a/packages/client-generator/src/generators/python/index.ts b/packages/client-generator/src/generators/python/index.ts index 96d0a83d0c..71e120112a 100644 --- a/packages/client-generator/src/generators/python/index.ts +++ b/packages/client-generator/src/generators/python/index.ts @@ -110,13 +110,67 @@ 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 }; +} + function writeDataclass( printer: Printer, name: string, properties: PropertyModel[], dateType: DateType, models: PythonModels, - description?: string + 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'); @@ -140,7 +194,10 @@ function writeDataclass( const { python, renamed } = fieldName(property.name); if (renamed && !pydantic) fieldMap.push([python, property.name]); const alias = renamed && pydantic ? `alias=${JSON.stringify(property.name)}` : undefined; - const baseType = pythonType(property.schema, dateType); + const baseType = + pinned?.property === property.name + ? `Literal[${JSON.stringify(pinned.value)}]` + : pythonType(property.schema, dateType); if (property.required) { const value = alias === undefined ? '' : ` = Field(${alias})`; printer.line(`${python}: ${baseType}${value}`); @@ -168,6 +225,10 @@ export function renderPythonModels( models: PythonModels = 'dataclass' ): string { const printer = new Printer(' '); + const { pins, unions } = + models === 'pydantic' + ? pydanticDiscriminators(model) + : { pins: new Map(), unions: new Map() }; printer.line('from __future__ import annotations'); printer.blank(); if (models === 'dataclass') printer.line('from dataclasses import dataclass'); @@ -186,6 +247,7 @@ export function renderPythonModels( '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. @@ -217,7 +279,8 @@ export function renderPythonModels( flat.properties, dateType, models, - flat.description ?? schema.description + flat.description ?? schema.description, + pins.get(name) ); continue; } @@ -232,7 +295,12 @@ export function renderPythonModels( .join(', '); printer.line(`# Discriminated by "${cases.property}": ${table}`); } - printer.line(`${className(name)} = ${pythonType(schema, dateType)}`); + const field = unions.get(name); + const union = + field === undefined + ? pythonType(schema, dateType) + : `Annotated[${pythonType(schema, dateType)}, Field(discriminator=${JSON.stringify(field)})]`; + printer.line(`${className(name)} = ${union}`); printer.blank(); }); } @@ -295,10 +363,16 @@ function writePythonServers(printer: Printer, model: ApiModel): void { printer.blank(); } -/** `DISCRIMINATORS[Pet] = ("petType", {"cat": Cat, ...})` registration lines. */ -function discriminatorRegistrations(model: ApiModel): string[] { +/** + * `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 @@ -716,6 +790,7 @@ export const pythonGenerator: Generator = ({ model, outputPath, emit, options }) const errorMode = emit.errorMode ?? 'throw'; const dateType = emit.dateType ?? 'string'; const models = (options?.models as PythonModels | undefined) ?? 'dataclass'; + const pydantic = models === 'pydantic' ? pydanticDiscriminators(model) : undefined; const printer = new Printer(' '); printer.line( `# Generated by @redocly/client-generator (python) from "${model.title}" ${model.version}.` @@ -748,7 +823,7 @@ export const pythonGenerator: Generator = ({ model, outputPath, emit, options }) printer.blank(); } printer.blank(); - const registrations = discriminatorRegistrations(model); + const registrations = discriminatorRegistrations(model, new Set(pydantic?.unions.keys())); if (registrations.length > 0) { printer.line('# Discriminated unions dispatch by their property inside decode().'); for (const registration of registrations) printer.line(registration); diff --git a/tests/e2e/generate-client/python.test.ts b/tests/e2e/generate-client/python.test.ts index 3faa43c4a5..f91cd58dcb 100644 --- a/tests/e2e/generate-client/python.test.ts +++ b/tests/e2e/generate-client/python.test.ts @@ -139,4 +139,31 @@ describe('generate-client python generator, models: pydantic (end-to-end)', () = expect(result.status, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`).toBe(0); expect(result.stdout).toContain('PYDANTIC_ROUND_TRIP_OK'); }); + + it.skipIf(!hasPydantic)('resolves a discriminated union nested in a model, not by shape', () => { + // Pydantic resolves a nested union itself, so the discriminator has to reach the + // annotation: `MenuItem` lives inside `MenuItemList.items`, never at the top level. + const item = [ + '{"category": "dessert", "calories": 400, "id": "mi_1", "name": "Cake",', + '"price": 500, "createdAt": "2026-01-01T00:00:00Z",', + '"updatedAt": "2026-01-01T00:00:00Z", "object": "menuItem"}', + ].join(' '); + const script = [ + 'import sys', + `sys.path.insert(0, ${JSON.stringify(dir)})`, + 'import client', + `item = ${item}`, + 'page = {"limit": 1, "endCursor": "c", "startCursor": "c",', + ' "hasNextPage": False, "hasPrevPage": False, "total": 1}', + 'listed = client.decode(client.MenuItemList, {"object": "list", "page": page, "items": [item]})', + 'assert type(listed.items[0]).__name__ == "Dessert", type(listed.items[0])', + // The top level goes through the same annotation. + 'assert type(client.decode(client.MenuItem, item)).__name__ == "Dessert"', + 'assert client.encode(listed)["items"][0]["category"] == "dessert"', + 'print("PYDANTIC_DISCRIMINATOR_OK")', + ].join('\n'); + const result = spawnSync('python3', ['-c', script], { encoding: 'utf-8' }); + expect(result.status, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`).toBe(0); + expect(result.stdout).toContain('PYDANTIC_DISCRIMINATOR_OK'); + }); }); From fee9559d1c6fecb149c9ffff696d7e4687daf041 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 19 Aug 2026 16:47:35 +0300 Subject: [PATCH 203/211] feat(client-generator)!: one input object per operation, grouped by layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A generated TypeScript operation took positional arguments and was exported twice: as a wrapper function and as a method on the client instance. The same name therefore had two argument shapes, and the wrapper's shape stopped being shorter as soon as an operation had more than one kind of input — a required body landing after an optional query bag forced `updateOrder('ord_1', {}, body)`. Operations now take one object, grouped by transport layer: updateOrder({ path: { orderId }, query: { dryRun }, headers: {…}, body: {…} }) `argsStyle: grouped` is the default. `flat` remains, redefined as the same object with the layers merged into one level; it merges a required object body and keeps a `body` key for a body it cannot merge (optional, array, scalar, binary). An operation whose merged names would collide keeps the grouped shape. The module-level exports are bindings of the client's own methods, so an operation is one function reachable two ways rather than two functions. That deletes the wrapper emitter, the flat-iterator patch it needed, the path-param binding identifiers, and the guard that rejected a path parameter named after an argument slot — a layer key cannot collide with a wire name. `Params` is now `Query`, beside a new `Path`. The runtime converts a merged call using the descriptor's parameter list, so both styles share one path through `splitArgs`, and the generated CLI builds whichever shape its client takes. --- .changeset/agent-friendly-generators.md | 2 +- docs/@v2/commands/generate-client.md | 6 +- docs/@v2/configuration/reference/client.md | 6 +- .../@v2/guides/migrate-to-generated-client.md | 2 +- docs/@v2/guides/use-generated-client.md | 92 ++-- .../skills/typescript-generator/SKILL.md | 15 +- .../src/__tests__/index.test.ts | 13 +- .../client-assembly.test.ts.snap | 93 ++-- .../__tests__/client-assembly.test.ts | 93 ++-- .../src/emitters/__tests__/descriptor.test.ts | 22 +- .../__tests__/operation-signature.test.ts | 22 +- .../src/emitters/__tests__/operations.test.ts | 216 +++++----- .../src/emitters/__tests__/swr.test.ts | 73 ++-- .../emitters/__tests__/tanstack-query.test.ts | 8 +- packages/client-generator/src/emitters/cli.ts | 30 +- .../src/emitters/client-assembly.ts | 52 +-- .../src/emitters/operation-signature.ts | 44 +- .../src/emitters/render-client.ts | 252 +++++------ .../src/emitters/runtime-sources.ts | 21 +- packages/client-generator/src/emitters/swr.ts | 13 +- .../src/emitters/tanstack-query.ts | 35 +- .../src/emitters/wrapper-support.ts | 22 +- .../src/generators/cli/index.ts | 1 + .../src/generators/swr/index.ts | 1 - .../src/generators/tanstack-query/index.ts | 1 + .../src/generators/typescript/AGENTS.md | 15 +- .../src/generators/typescript/index.ts | 29 +- .../__tests__/sanitize-identifiers.test.ts | 31 +- .../src/intermediate-representation/build.ts | 7 +- .../sanitize-identifiers.ts | 26 -- .../src/runtime/__tests__/cli.test.ts | 12 +- .../runtime/__tests__/create-client.test.ts | 56 ++- .../src/runtime/__tests__/paginate.test.ts | 42 +- packages/client-generator/src/runtime/cli.ts | 43 +- .../src/runtime/create-client.ts | 132 ++++-- .../client-generator/src/runtime/paginate.ts | 27 +- .../client-generator/src/runtime/types.ts | 6 + .../e2e/generate-client/args-grouped.test.ts | 8 +- tests/e2e/generate-client/auth.test.ts | 2 +- .../base-consumer/index-cancel.ts | 2 +- .../generate-client/base-consumer/index.ts | 6 +- tests/e2e/generate-client/base.test.ts | 8 +- .../cafe-consumer/index-configure.ts | 10 +- .../generate-client/cafe-consumer/index.ts | 55 ++- tests/e2e/generate-client/cafe.snapshot.ts | 406 +++++++----------- tests/e2e/generate-client/cafe.test.ts | 28 +- tests/e2e/generate-client/envelope.test.ts | 8 +- tests/e2e/generate-client/error-mode.test.ts | 2 +- .../configure-and-middleware/src/main.ts | 6 +- .../examples/custom-pagination/src/main.ts | 4 +- .../.claude/skills/client-generators/SKILL.md | 31 +- .../.claude/skills/php-generator/SKILL.md | 5 + .../examples/node-native/src/main.ts | 2 +- .../examples/package-runtime/src/main.ts | 7 +- .../examples/pagination/src/main.ts | 4 +- .../examples/vendored-edge/worker.ts | 6 +- .../zero-install-quickstart/src/api/client.ts | 187 +++++--- .../zero-install-quickstart/src/main.ts | 7 +- tests/e2e/generate-client/extension.test.ts | 2 +- .../identifier-injection.test.ts | 12 +- tests/e2e/generate-client/middleware.test.ts | 8 +- tests/e2e/generate-client/mock.test.ts | 2 +- tests/e2e/generate-client/multipart.test.ts | 4 +- .../package-runtime-consumer/index.ts | 6 +- .../pagination-consumer/index-abort.ts | 5 +- .../pagination-consumer/index-offset.ts | 4 +- .../pagination-consumer/index-package.ts | 2 +- .../pagination-consumer/index.ts | 17 +- tests/e2e/generate-client/pagination.test.ts | 24 +- tests/e2e/generate-client/parse-as.test.ts | 6 +- .../generate-client/path-param-idents.test.ts | 22 +- .../e2e/generate-client/query-styles.test.ts | 2 +- .../generate-client/redocly-config.test.ts | 4 +- tests/e2e/generate-client/retry.test.ts | 4 +- .../e2e/generate-client/spec-versions.test.ts | 7 +- .../sse-consumer/index-abort.ts | 2 +- .../sse-consumer/index-connect-retry.ts | 2 +- tests/e2e/generate-client/sse.test.ts | 10 +- .../tanstack-query.runtime.test.ts | 2 +- .../generate-client/tanstack-query.test.ts | 8 +- 80 files changed, 1278 insertions(+), 1232 deletions(-) diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index 7ed0551b3f..fb9374ed0e 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -16,7 +16,7 @@ Added an `eject-generator` command that vendors any built-in generator, with its Renamed pagination operation extension from `x-redocly-pagination` to `x-redoclyPagination`. The previous name is no longer read. -**Note**: with `argsStyle: flat` (the default), a free function's `.pages()`/`.items()` now take the same arguments as the function itself — `listOrders.pages({ limit: 20 })` rather than `listOrders.pages({ params: { limit: 20 } })`. Grouped mode is unchanged, and the compiler points at every call site that needs the edit. +**Note**: every generated TypeScript operation now takes ONE input object, and `argsStyle: grouped` is the default. The input groups its values by transport layer — `path`, `query`, `headers`, `cookies`, and `body` as sibling keys — so `updateOrder({ path: { orderId }, body })` replaces the old positional call. `argsStyle: flat` remains, redefined as the same object with the layers merged into one level (`updateOrder({ orderId, ...body })`); it merges the properties of a required object body, and keeps a `body` key for a body it cannot merge. Two smaller consequences: the module-level exports are now bindings of the client's own methods (`export const { updateOrder } = client;`) rather than wrapper functions, so one operation can no longer have two argument shapes; and the query-parameter type alias is `Query` (was `Params`), beside a new `Path`. The compiler points at every call site that needs the edit. **Note**: the generated TypeScript client no longer exports per-scheme credential setters (`setBearer`, `setBasicAuth`, `setApiKey`). Set credentials with `configure({ auth: … })` or on the instance with `client.auth.bearer(…)`, `client.auth.basic(…)`, and `client.auth.apiKey('', …)`. One consequence is welcome: a setter name is no longer reserved, so an operation or schema of that name keeps it. diff --git a/docs/@v2/commands/generate-client.md b/docs/@v2/commands/generate-client.md index ea38f918a1..de026c25ed 100644 --- a/docs/@v2/commands/generate-client.md +++ b/docs/@v2/commands/generate-client.md @@ -21,8 +21,8 @@ import { listOrders, createOrder, configure } from './client.js'; configure({ auth: { bearer: process.env.API_TOKEN } }); -const orders = await listOrders({ status: 'open', limit: 10 }); -const created = await createOrder({ items: [{ menuItemId: 'itm_1', quantity: 2 }] }); +const orders = await listOrders({ query: { status: 'open', limit: 10 } }); +const created = await createOrder({ body: { items: [{ menuItemId: 'itm_1', quantity: 2 }] } }); ``` The client has no dependencies, and it carries the behavior an API needs: auth for every scheme the description declares, opt-in retries, timeouts, middleware, pagination iterators, and typed server-sent events. @@ -79,7 +79,7 @@ redocly generate-client [--help] [--version] | `--runtime` | string | The location of the client engine. See [Choose a runtime](#choose-a-runtime).
**Possible values:** `inline`, `package`. Default: `inline`. | | `--import-ext` | string | The extension in the generated relative imports. See [Run with Node directly](../guides/use-generated-client.md#run-with-node-directly).
**Possible values:** `js` (the tsc/bundler convention), `ts` (for Node's built-in type stripping). Default: `js`. | | `--generator` | [string] | The generator to run: a built-in name, or the path or package of a custom generator. Repeat the flag to run more than one generator. Default value is `typescript`. See [Generators](../guides/use-generated-client.md#generators) for the full list. | -| `--args-style` | string | Sets how you pass inputs to operations. See [Argument style](../guides/use-generated-client.md#argument-style).
**Possible values:** `flat`, `grouped`. Default: `flat`. | +| `--args-style` | string | Sets how you pass inputs to operations. See [Argument style](../guides/use-generated-client.md#argument-style).
**Possible values:** `grouped`, `flat`. Default: `grouped`. | | `--error-mode` | string | Sets how operations report HTTP errors. See [Error handling](../guides/use-generated-client.md#error-handling).
**Possible values:** `throw`, `result`. Default: `throw`. | | `--date-type` | string | The type of the `date`/`date-time` fields. If you use `Date`, also use the `transformers` generator.
**Possible values:** `string`, `Date`. Default: `string`. | | `--mock-data` | string | The data mode for the `mock` generator.
**Possible values:** `static` (deterministic literals), `faker` (`@faker-js/faker` calls). Default: `static`. | diff --git a/docs/@v2/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md index c28d50bf7e..8fdf367309 100644 --- a/docs/@v2/configuration/reference/client.md +++ b/docs/@v2/configuration/reference/client.md @@ -28,7 +28,7 @@ As an alternative, pass `pagination` to the programmatic `generateClient(...)`. | `outputMode` | string | The file layout: `single` or `split`. This option applies to TypeScript output only. The `python`, `go`, and `php` SDKs always emit one self-contained file. | | `runtime` | string | The runtime distribution: `inline` or `package`. This option applies to TypeScript output only. The `python`, `go`, and `php` SDKs always embed their runtime. | | `importExt` | string | The extension in generated relative imports: `js` (default, for tsc and bundlers) or `ts` (for Node's built-in type stripping). This option applies to TypeScript output only. | -| `argsStyle` | string | How the client receives operation inputs: `flat` or `grouped`. This option applies to TypeScript output only. Each language SDK follows its own idiom (keyword arguments, named arguments, a params struct). | +| `argsStyle` | string | How the client receives operation inputs: `grouped` (default) groups them by transport layer (`path`, `query`, `headers`, `cookies`, `body`), and `flat` merges them into one object. This option applies to TypeScript output only. Each language SDK follows its own idiom (keyword arguments, named arguments, a params struct). | | `errorMode` | string | How operations report HTTP errors: `throw` or `result`. The `python` SDK implements both. The `go` and `php` SDKs support only `throw`, because that is the language idiom, and they reject `result`. | | `dateType` | string | The type of `date`/`date-time` fields: `string` or `Date`. Every language applies it: `Date` in TypeScript, `datetime`/`date` in Python, `time.Time`/`Date` in Go, `DateTimeImmutable` in PHP. | | `mockData` | string | The data mode for the `mock` generator: `static` or `faker`. | @@ -86,7 +86,7 @@ CLI flags override the resolved configuration. client: generators: - typescript - argsStyle: flat + argsStyle: grouped apis: cafe: root: ./openapi.yaml @@ -95,7 +95,7 @@ apis: generators: - typescript - zod - argsStyle: grouped + argsStyle: flat orders: root: ./orders.yaml # no client block — uses the top-level one clientOutput: ./src/api/orders.client.ts diff --git a/docs/@v2/guides/migrate-to-generated-client.md b/docs/@v2/guides/migrate-to-generated-client.md index 7a389bece6..67dc4ef585 100644 --- a/docs/@v2/guides/migrate-to-generated-client.md +++ b/docs/@v2/guides/migrate-to-generated-client.md @@ -64,7 +64,7 @@ const order = await getOrder(orderId); // After import { getOrderById } from '../api/generated/client.js'; -const order = await getOrderById(orderId); +const order = await getOrderById({ path: { orderId } }); ``` Three differences account for most of the compiler errors: diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index 07e224728d..3db812e9c3 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -294,7 +294,7 @@ for order, err := range api.ListOrdersItems(ctx, nil) { Every language gives credentials to a client instance, and the constructor is that one way. `createClient(OPERATIONS, { auth })` in TypeScript is the same thing as the constructors below. -TypeScript adds `configure({ auth })` for one reason: it also exports a module-level client, which the [free functions](#authentication) call, and `configure` is how you set up that instance. +TypeScript adds `configure({ auth })` for one reason: it also exports a module-level client, whose methods the module exports by name, and `configure` is how you set up that instance. The Python, PHP, and Go SDKs export no module-level client, so they need no equivalent. Auth accepts a static credential, or a provider function that the client resolves for each request: @@ -434,7 +434,7 @@ redocly generate-client openapi.yaml -o src/api/client.ts --import-ext ts // src/main.ts import { listMenuItems } from './api/client.ts'; -const menu = await listMenuItems({ limit: 3 }); +const menu = await listMenuItems({ query: { limit: 3 } }); ``` ```bash @@ -511,26 +511,36 @@ const publicApi = createClient(OPERATIONS, { serverUrl: 'https://api.exampl ## Argument style -By default (`--args-style flat`), each operation takes positional arguments. -The order is: path parameters in URL order, then `params` (query), `body`, `headers`, and `cookies`. -The per-call `init` comes last. -The client serializes cookie parameters into the `Cookie` request header, and browsers refuse to set this header. -Because of this, cookie parameters, like cookie apiKey auth, work only in server-side clients. -With `--args-style grouped`, one `vars` object holds every input. -Its type is the operation's `Variables`: +Every operation takes one input object and an optional per-call `init`. +By default (`--args-style grouped`), the input groups its values by transport layer: `path`, `query`, `headers`, `cookies`, and `body`. +Each key is a sibling of the others, and the type of the whole object is the operation's `Variables`: ```ts -// flat (default) -await updateOrder('ord_01khr…', { ...orderBody }); +await updateOrder({ + path: { orderId: 'ord_01khr…' }, + query: { dryRun: true }, + headers: { 'X-Request-Id': requestId }, + body: { ...orderBody }, +}); +``` -// grouped — order-independent, a good fit for React Query / SWR mutationFns -await updateOrder({ orderId: 'ord_01khr…', body: { ...orderBody } }); +The layer names come from the description itself, so a call reads like the operation it calls, adding a parameter never changes how existing calls are written, and no name can collide with another. + +With `--args-style flat`, the same values are merged into one level, which is shorter for an operation with a single kind of input: + +```ts +await updateOrder({ orderId: 'ord_01khr…', dryRun: true, ...orderBody }); ``` -An unknown top-level key in the grouped object fails the call with a `TypeError` that names the key. -An example is a leftover flat-style `{ limit: 10 }` instead of `{ params: { limit: 10 } }`. -TypeScript catches this at compile time. -The runtime check covers transpilers that skip type checks. +Flat merges the properties of a required object body. +A body that is optional, or that is not an object (an array, a scalar, or a binary payload), keeps its own `body` key. +When one name would arrive from two layers, that operation keeps the grouped shape, because a merged call could not say which value is which. + +The client serializes cookie parameters into the `Cookie` request header, and browsers refuse to set this header. +Because of this, cookie parameters, like cookie apiKey auth, work only in server-side clients. + +An unknown top-level key fails the call with a `TypeError` that names the key and lists the layers. +TypeScript catches this at compile time; the runtime check covers transpilers that skip type checks. Because of this, a call with the wrong shape never drops data silently. ## Read-only properties @@ -565,13 +575,13 @@ The `error` type comes from the 4xx/5xx bodies in the description: ```ts // throw (default) try { - const order = await getOrderById('ord_123'); + const order = await getOrderById({ path: { orderId: 'ord_123' } }); } catch (err) { if (err instanceof ApiError) console.error(err.status, err.body); } // result -const { data, error, response } = await getOrderById('ord_123'); +const { data, error, response } = await getOrderById({ path: { orderId: 'ord_123' } }); if (error) console.error(response.status, error.title); else console.log(data.id); ``` @@ -672,7 +682,7 @@ Configure it through `ClientConfig`, with an optional per-call override: ```ts configure({ retry: { retries: 3 } }); // the module's client instance const other = createClient(OPERATIONS, { retry: { retries: 3 } }); // another instance -await getOrderById('ord_123', {}, { retry: { retries: 5 } }); // per call +await getOrderById({ path: { orderId: 'ord_123' } }, { retry: { retries: 5 } }); // per call ``` By default, the client retries only **idempotent** methods (`GET`, `HEAD`, `PUT`, `DELETE`, `OPTIONS`). @@ -724,15 +734,18 @@ It **fully replaces** the default. To examine a response body, read `ctx.response.clone()`, because the body is a single-use stream: ```ts -await createOrder(body, { - retry: { - retries: 3, - retryOn: async (ctx) => { - if (ctx.error) return true; // transport error - return (ctx.response?.status ?? 0) >= 500; // server error +await createOrder( + { body }, + { + retry: { + retries: 3, + retryOn: async (ctx) => { + if (ctx.error) return true; // transport error + return (ctx.response?.status ?? 0) >= 500; // server error + }, }, - }, -}); + } +); ``` ## Query serialization @@ -794,15 +807,18 @@ Sometimes you need response headers, for example pagination totals, rate limits, To get them without a switch to `--error-mode result`, pass `{ envelope: true }` on that call: ```ts -// Flat args (default): query/body slots, then per-call init. -const { data, headers, response } = await listCustomers({ limit: 1 }, { envelope: true }); +// The inputs come first, the per-call options second. +const { data, headers, response } = await listCustomers( + { query: { limit: 1 } }, + { envelope: true } +); headers.paginationTotal; // number — required Pagination-Total in the description headers.xFlag; // boolean | undefined — optional X-Flag response.headers.get('X-Undocumented'); // anything not declared in OpenAPI -// Grouped args / instance client: trailing init is always separate. -const envelope = await client.listCustomers({ params: { limit: 1 } }, { envelope: true }); +// The instance client is the same function under another name. +const envelope = await client.listCustomers({ query: { limit: 1 } }, { envelope: true }); ``` - `headers` is a safe camelCase object of the headers declared on the operation's success response. @@ -900,7 +916,7 @@ A union without a usable discriminator gets no guard. ## Server-Sent Events An operation whose `2xx` response declares `text/event-stream` generates as a typed **async-generator function**. -The output is a client method plus the matching free function. +The client method is exported under its own name, like every other operation. No flag is required. The `data` of each event is typed from the OpenAPI 3.2 `itemSchema`. If `itemSchema` is absent, the type falls back to the media `schema`, then to `string`. @@ -917,7 +933,7 @@ for await (const ev of streamMessages()) { The stream **reconnects automatically** after a dropped connection. It resumes from the last event id with `Last-Event-ID`. The backoff uses the server's `retry:`, then `reconnectDelay`, then 1 second, with a cap of 30 seconds. -Tune per call with `{ reconnect: false }` or `{ reconnectDelay: 500 }`. +Tune per call with the second argument: `streamMessages({}, { reconnect: false })` or `{ reconnectDelay: 500 }`. A `break` from the loop, or an aborted `AbortSignal`, ends the stream cleanly with no throw. SSE always throws `ApiError` on a non-2xx initial response, regardless of `--error-mode`. @@ -959,7 +975,7 @@ The iterator never sets it, so pass your page size in `params` yourself. ```ts import { client } from './client.ts'; -for await (const order of client.listOrders.items({ params: { limit: 20 } })) { +for await (const order of client.listOrders.items({ query: { limit: 20 } })) { console.log(order.id); // `order` is `Order` — resolved from the response schema at generate time } @@ -968,9 +984,7 @@ for await (const page of client.listOrders.pages()) { } ``` -The flat free functions keep both iterators, and the iterators take the same arguments as the function they hang on. -With `--args-style flat`, `listOrders({ limit: 20 })` and `listOrders.pages({ limit: 20 })` have the same shape. -With `--args-style grouped`, both take the grouped object. +`listOrders` and `listOrders.pages` are the same function and its own member, so they take the same input in either argument style. To resume, pass the advance parameter in the initial args. Iteration then starts from that point, not from the beginning. @@ -980,7 +994,7 @@ The client forwards it to every page request: ```ts const controller = new AbortController(); for await (const page of client.listOrders.pages( - { params: { cursor: 'c2' } }, // start from a saved cursor (or offset/page number) + { query: { cursor: 'c2' } }, // start from a saved cursor (or offset/page number) { signal: controller.signal } )) { // … 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 9c29d1d400..bede4fa750 100644 --- a/packages/client-generator/eject-assets/skills/typescript-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/typescript-generator/SKILL.md @@ -12,8 +12,8 @@ to `generators/typescript.mjs` that has no covering sentence here is incomplete. ## What it emits The typed TypeScript client itself: model types with JSDoc, type guards, the `Ops` -type map, the `OPERATIONS` descriptor table, a `client` instance, flat call sugar, -and either the embedded runtime (`runtime: inline`) or imports from +type map, the `OPERATIONS` descriptor table, a `client` instance, one binding per +operation, and either the embedded runtime (`runtime: inline`) or imports from `@redocly/client-generator` (`runtime: package`). ## Design decisions that must hold @@ -32,14 +32,21 @@ and either the embedded runtime (`runtime: inline`) or imports from description — the only real fix), a name that isn't a valid identifier, or a clash with a name the generated module already declares. A vague "collides or is invalid" message leaves the publisher unable to act. +- **One operation, one function, one input shape.** The module-level names are bindings + of the client's own methods (`export const { getOrder } = client;`), never wrappers, so + `getOrder` and `client.getOrder` cannot disagree about their arguments. `argsStyle` + shapes the method itself: `grouped` (the default) namespaces the inputs by transport + layer — `path`, `query`, `headers`, `cookies`, `body` — and `flat` merges them into one + object, which the runtime converts back using the descriptor's own parameter list. An + operation whose merged names would collide keeps the grouped shape. - **Throw mode returns the body**; `{ envelope: true }` opts into `{ data, headers, response }` with typed declared headers. Result mode returns `{ data, error, response }` and ignores `envelope`. ## Emitters that implement it -`emitters/client-assembly.ts` (orchestration), `render-client.ts` (Ops, aliases, flat -sugar), `descriptor.ts`, `ts-type.ts`/`ts-literal.ts` (type + data text), `sse.ts`, +`emitters/client-assembly.ts` (orchestration), `render-client.ts` (Ops, aliases, input +shapes), `descriptor.ts`, `ts-type.ts`/`ts-literal.ts` (type + data text), `sse.ts`, `pagination.ts`, `response-headers.ts`, `inline-runtime.ts`, `setup-bake.ts`. ## Ejecting it diff --git a/packages/client-generator/src/__tests__/index.test.ts b/packages/client-generator/src/__tests__/index.test.ts index f2d3a7df62..129c4366cb 100644 --- a/packages/client-generator/src/__tests__/index.test.ts +++ b/packages/client-generator/src/__tests__/index.test.ts @@ -222,9 +222,7 @@ describe('generateClient — end-to-end orchestration', () => { expect(result.bytes).toBeGreaterThan(0); const contents = await readFile(output, 'utf-8'); - expect(contents).toContain( - 'export const ping = (init?: I): Promise, I>>' - ); + expect(contents).toContain('export const { ping } = client;'); expect(contents).toContain('// Generated by @redocly/client-generator'); // bytes should match what we wrote. expect(result.bytes).toBe(Buffer.byteLength(contents, 'utf-8')); @@ -313,9 +311,8 @@ describe('generateClient — end-to-end orchestration', () => { 'pagination: { style: "cursor", param: "cursor", nextCursor: "/nextCursor", items: "/orders" }' ); expect(contents).toContain('item: string;'); - expect(contents).toContain( - 'init: RequestOptions = {}) => client.listOrders.pages({ params }, init)' - ); + // `.pages`/`.items` ride the client method the binding points at. + expect(contents).toContain('export const { listOrders } = client;'); }); it('normalizes a Swagger 2.0 document before generating', async () => { @@ -356,9 +353,7 @@ describe('generateClient — end-to-end orchestration', () => { expect(result.bytes).toBeGreaterThan(0); const contents = await readFile(output, 'utf-8'); - expect(contents).toContain( - 'export const listItems = (' - ); + expect(contents).toContain('export const { listItems } = client;'); expect(contents).toContain('export type Item'); expect(contents).toContain('serverUrl: "https://api.example.com/v1"'); }); diff --git a/packages/client-generator/src/emitters/__tests__/__snapshots__/client-assembly.test.ts.snap b/packages/client-generator/src/emitters/__tests__/__snapshots__/client-assembly.test.ts.snap index 36bfe6f414..2160d2aa2c 100644 --- a/packages/client-generator/src/emitters/__tests__/__snapshots__/client-assembly.test.ts.snap +++ b/packages/client-generator/src/emitters/__tests__/__snapshots__/client-assembly.test.ts.snap @@ -8,7 +8,7 @@ exports[`emitClientSingleFile (package arm) > matches the golden output for a sm * T (v1.0.0) */ -import { createClient, type EnvelopeResult, type OperationDescriptor, type RequestOptions, type SseOptions } from '@redocly/client-generator'; +import { createClient, type OperationDescriptor } from '@redocly/client-generator'; export type Order = { id: string; @@ -22,13 +22,17 @@ export type OrderEvent = {}; export type GetOrderResult = Order; -export type GetOrderParams = { +export type GetOrderPath = { + orderId: string; +}; + +export type GetOrderQuery = { expand?: string; }; export type GetOrderVariables = { - orderId: string; - params?: GetOrderParams; + path: GetOrderPath; + query?: GetOrderQuery; }; /** @@ -38,8 +42,8 @@ export type GetOrderVariables = { export type Ops = { getOrder: { args: { - orderId: string; - params?: GetOrderParams; + path: GetOrderPath; + query?: GetOrderQuery; }; result: GetOrderResult; }; @@ -71,10 +75,7 @@ export type OperationTag = Extract<(typeof OPERATIONS)[keyof typeof OPERATIONS], export const client = createClient(OPERATIONS, { serverUrl: "https://cafe.example.com", clientHeader: "redocly-client-generator" }); export const { configure, use } = client; -export const getOrder = (orderId: string, params: { - expand?: string; -} = {}, init?: I): Promise, I>> => client.getOrder({ orderId, params }, init) as Promise, I>>; -export const streamEvents = (init: SseOptions = {}) => client.streamEvents({}, init); +export const { getOrder, streamEvents } = client; export { ApiError, createClient, defaultRetryOn, TimeoutError } from '@redocly/client-generator'; export type { ClientConfig, Envelope, Middleware, RequestOptions, ServerSentEvent, SseOptions } from '@redocly/client-generator'; @@ -89,7 +90,7 @@ exports[`emitClientSingleFile — pagination > matches the golden output for a p * T (v1.0.0) */ -import { createClient, type EnvelopeResult, type OperationDescriptor, type RequestOptions } from '@redocly/client-generator'; +import { createClient, type OperationDescriptor } from '@redocly/client-generator'; export type Order = {}; @@ -106,24 +107,28 @@ export type OrderPage = { export type ListOrdersResult = OrderPage; -export type ListOrdersParams = { +export type ListOrdersQuery = { cursor?: string; limit?: string; }; export type ListOrdersVariables = { - params?: ListOrdersParams; + query?: ListOrdersQuery; }; export type GetOrderResult = Order; -export type GetOrderParams = { +export type GetOrderPath = { + orderId: string; +}; + +export type GetOrderQuery = { expand?: string; }; export type GetOrderVariables = { - orderId: string; - params?: GetOrderParams; + path: GetOrderPath; + query?: GetOrderQuery; }; /** @@ -133,15 +138,15 @@ export type GetOrderVariables = { export type Ops = { listOrders: { args: { - params?: ListOrdersParams; + query?: ListOrdersQuery; }; result: ListOrdersResult; item: Order; }; getOrder: { args: { - orderId: string; - params?: GetOrderParams; + path: GetOrderPath; + query?: GetOrderQuery; }; result: GetOrderResult; }; @@ -168,19 +173,7 @@ export type OperationTag = Extract<(typeof OPERATIONS)[keyof typeof OPERATIONS], export const client = createClient(OPERATIONS, { serverUrl: "https://api.example.com", clientHeader: "redocly-client-generator" }); export const { configure, use } = client; -export const listOrders = Object.assign((params: { - cursor?: string; - limit?: string; -} = {}, init?: I): Promise, I>> => client.listOrders({ params }, init) as Promise, I>>, { pages: (params: { - cursor?: string; - limit?: string; -} = {}, init: RequestOptions = {}) => client.listOrders.pages({ params }, init), items: (params: { - cursor?: string; - limit?: string; -} = {}, init: RequestOptions = {}) => client.listOrders.items({ params }, init) }); -export const getOrder = (orderId: string, params: { - expand?: string; -} = {}, init?: I): Promise, I>> => client.getOrder({ orderId, params }, init) as Promise, I>>; +export const { listOrders, getOrder } = client; export { ApiError, createClient, defaultRetryOn, TimeoutError } from '@redocly/client-generator'; export type { ClientConfig, Envelope, Middleware, RequestOptions } from '@redocly/client-generator'; @@ -195,7 +188,7 @@ exports[`emitClientSingleFile — pagination > matches the golden output for a r * T (v1.0.0) */ -import { createClient, type OperationDescriptor, type RequestOptions, type Result } from '@redocly/client-generator'; +import { createClient, type OperationDescriptor, type Result } from '@redocly/client-generator'; export type Order = {}; @@ -212,26 +205,30 @@ export type OrderPage = { export type ListOrdersResult = OrderPage; -export type ListOrdersParams = { +export type ListOrdersQuery = { cursor?: string; limit?: string; }; export type ListOrdersVariables = { - params?: ListOrdersParams; + query?: ListOrdersQuery; }; export type GetOrderResult = Order; export type GetOrderError = Problem; -export type GetOrderParams = { +export type GetOrderPath = { + orderId: string; +}; + +export type GetOrderQuery = { expand?: string; }; export type GetOrderVariables = { - orderId: string; - params?: GetOrderParams; + path: GetOrderPath; + query?: GetOrderQuery; }; /** @@ -241,7 +238,7 @@ export type GetOrderVariables = { export type Ops = { listOrders: { args: { - params?: ListOrdersParams; + query?: ListOrdersQuery; }; result: Result; mode: "result"; @@ -250,8 +247,8 @@ export type Ops = { }; getOrder: { args: { - orderId: string; - params?: GetOrderParams; + path: GetOrderPath; + query?: GetOrderQuery; }; result: Result; mode: "result"; @@ -279,19 +276,7 @@ export type OperationTag = Extract<(typeof OPERATIONS)[keyof typeof OPERATIONS], export const client = createClient(OPERATIONS, { serverUrl: "https://api.example.com", errorMode: "result", clientHeader: "redocly-client-generator" }); export const { configure, use } = client; -export const listOrders = Object.assign((params: { - cursor?: string; - limit?: string; -} = {}, init: RequestOptions = {}) => client.listOrders({ params }, init), { pages: (params: { - cursor?: string; - limit?: string; -} = {}, init: RequestOptions = {}) => client.listOrders.pages({ params }, init), items: (params: { - cursor?: string; - limit?: string; -} = {}, init: RequestOptions = {}) => client.listOrders.items({ params }, init) }); -export const getOrder = (orderId: string, params: { - expand?: string; -} = {}, init: RequestOptions = {}) => client.getOrder({ orderId, params }, init); +export const { listOrders, getOrder } = client; export { ApiError, createClient, defaultRetryOn, TimeoutError } from '@redocly/client-generator'; export type { ClientConfig, Envelope, Middleware, RequestOptions, Result } from '@redocly/client-generator'; diff --git a/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts b/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts index 538f21e043..c08fcdfdf7 100644 --- a/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts +++ b/packages/client-generator/src/emitters/__tests__/client-assembly.test.ts @@ -91,10 +91,10 @@ describe('emitClientSingleFile (package arm)', () => { const output = emit(CAFE, { serverUrl: 'https://x' }); it('imports from the package instead of inlining the runtime template', () => { - // Only the names the file references: `TokenProvider` typed the removed apiKey - // setters, and an unused type import fails a consumer's `noUnusedLocals` build. + // Only the names the file references. The per-call option types went with the flat + // wrappers, and an unused type import fails a consumer's `noUnusedLocals` build. expect(output).toContain( - "import { createClient, type EnvelopeResult, type OperationDescriptor, type RequestOptions, type SseOptions } from '@redocly/client-generator';" + "import { createClient, type OperationDescriptor } from '@redocly/client-generator';" ); expect(output).not.toContain('__send'); expect(output).not.toContain('__buildUrl'); @@ -162,29 +162,22 @@ describe('emitClientSingleFile (package arm)', () => { expect(output).toContain('security: [[{ scheme: "bearerAuth"'); }); - it('emits flat sugar one-liners forwarding to the grouped client methods', () => { - // Throw-mode flat sugar is generic over `init` so `{ envelope: true }` narrows. - expect(output).toContain( - 'export const getOrder = (orderId: string, params: {' - ); - expect(output).toContain('=> client.getOrder({ orderId, params }, init) as Promise<'); + it('exports the client methods as bindings — one function per operation, no wrappers', () => { + // The module-level name IS the method, so importing it and reaching through the + // instance can never disagree about the arguments. expect(output).toContain( - 'export const createPet = (body: Pet, init?: I): Promise, I>'); - // SSE sugar takes SseOptions and returns the generator directly (no envelope). - expect(output).toContain( - 'export const streamEvents = (init: SseOptions = {}) => client.streamEvents({}, init);' + 'export const { getOrder, createPet, upload, streamEvents, configure_2 } = client;' ); + expect(output).not.toContain('=> client.getOrder('); + expect(output).not.toContain('=> client.streamEvents('); }); it('renames the colliding operation everywhere while the core members keep their names', () => { expect(output).toContain('configure_2: {'); expect(output).toContain('id: "configure"'); // descriptor id stays the spec operationId - expect(output).toContain( - 'export const configure_2 = (init?: I): Promise client.configure_2({}, init) as Promise<'); + // `configure` itself stays the client's own member; the operation rides the binding. + expect(output).toContain('export const { configure, use } = client;'); + expect(output).toContain('configure_2 } = client;'); }); it('re-exports the public surface', () => { @@ -196,7 +189,7 @@ describe('emitClientSingleFile (package arm)', () => { ); }); - it('keys flat-sugar path values by WIRE name when it differs from the ident', () => { + it('keys a path value by its WIRE name, which is what the runtime substitutes', () => { const model = modelWith([ operation({ name: 'getPet', @@ -207,11 +200,9 @@ describe('emitClientSingleFile (package arm)', () => { ]); // No options at all — the emitter's own defaults apply. const out = emit(model); - expect(out).toContain( - 'export const getPet = (pet_id: string, init?: I): Promise client.getPet({ "pet-id": pet_id }, init) as Promise<'); - expect(out).toContain('"pet-id": string;'); // Ops args + Variables alias, wire-keyed + expect(out).toContain('export type GetPetPath = {\n "pet-id": string;\n};'); + expect(out).toContain('path: GetPetPath;'); + expect(out).not.toContain('pet_id'); }); it('keeps sanitizer-collapsed path params distinct: identifier-safe wire name, renamed ident', () => { @@ -223,9 +214,11 @@ describe('emitClientSingleFile (package arm)', () => { successResponses: [response()], }), ]); - // `a-b` sanitizes to `a_b`, so the literal `a_b` param is deduped to `a_b_2` — - // but both forward under their wire names. - expect(emit(model)).toContain('client.compare({ "a-b": a_b, a_b: a_b_2 }, init) as Promise<'); + // Two wire names that sanitize alike stay distinct, because the layer keys them by + // wire name and never derives a binding identifier. + expect(emit(model)).toContain( + 'export type ComparePath = {\n "a-b": string;\n a_b: string;\n};' + ); }); it('layers a baked setup OVER the spec defaults and imports the contract types', () => { @@ -234,7 +227,7 @@ describe('emitClientSingleFile (package arm)', () => { setup: '{ config: { retry: { retries: 2 } } }', }); expect(out).toContain( - "import { createClient, mergeSetup, type ClientConfig, type EnvelopeResult, type Middleware, type OperationDescriptor, type RequestOptions } from '@redocly/client-generator';" + "import { createClient, mergeSetup, type ClientConfig, type Middleware, type OperationDescriptor } from '@redocly/client-generator';" ); expect(out).toContain( 'const __redoclySetup: { config?: ClientConfig; middleware?: Middleware[] } = { config: { retry: { retries: 2 } } };' @@ -267,16 +260,15 @@ describe('emitClientSingleFile (package arm)', () => { expect(out).toContain('type Result'); }); - it('grouped argsStyle destructures the client methods instead of flat one-liners', () => { - const out = emit(CAFE, { serverUrl: 'https://x', argsStyle: 'grouped' }); + it('argsStyle: flat merges the inputs and tells the runtime, keeping one binding', () => { + const out = emit(CAFE, { serverUrl: 'https://x', argsStyle: 'flat' }); expect(out).toContain( 'export const { getOrder, createPet, upload, streamEvents, configure_2 } = client;' ); - expect(out).not.toContain('=> client.getOrder('); - // No flat sugar → the per-call option types are not imported (only re-exported). - expect(out).toContain( - "import { createClient, type OperationDescriptor } from '@redocly/client-generator';" - ); + expect(out).toContain('argsStyle: "flat"'); + // Merged: the path param sits beside the query params, with no layer keys. + expect(out).toContain('export type GetOrderVariables = {'); + expect(out).not.toContain('path: GetOrderPath;'); }); it('threads one schemaNames set: a suppressed alias is inlined in Ops, never referenced', () => { @@ -326,7 +318,8 @@ describe('emitClientSingleFile (package arm)', () => { }), ]) ); - expect(out).toContain('=> client.ping({ headers }, init) as Promise<'); + expect(out).toContain('export type PingHeaders = {\n "X-Trace"?: string;\n};'); + expect(out).toContain('headers?: PingHeaders;'); }); it('matches the golden output for a small model', () => { @@ -456,7 +449,7 @@ describe('emitClientSingleFile — pagination', () => { 'pagination: { style: "cursor", param: "cursor", nextCursor: "/nextCursor", items: "/orders" }' ); expect(out).toMatch( - /listOrders: \{\n\s+args: \{\n\s+params\?: ListOrdersParams;\n\s+\};\n\s+result: ListOrdersResult;\n\s+item: Order;\n\s+\};/ + /listOrders: \{\n\s+args: \{\n\s+query\?: ListOrdersQuery;\n\s+\};\n\s+result: ListOrdersResult;\n\s+item: Order;\n\s+\};/ ); }); @@ -469,25 +462,13 @@ describe('emitClientSingleFile — pagination', () => { expect(out).toContain('pagination: { style: "cursor", param: "cursor",'); }); - it('attaches .pages/.items that take the same flat arguments as the call', () => { + it('the iterators ride the binding, so `.pages`/`.items` need no wrapper', () => { const out = emit(PAGINATED, { pagination: config }); - expect(out).toContain( - 'export const listOrders = Object.assign((params: {' - ); - expect(out).toContain('=> client.listOrders({ params }, init) as Promise<'); - // One exported function, one argument shape: the iterators translate to the - // client method's grouped form exactly as the call above does. - expect(out).toContain( - 'init: RequestOptions = {}) => client.listOrders.pages({ params }, init)' - ); - expect(out).toContain( - 'init: RequestOptions = {}) => client.listOrders.items({ params }, init)' - ); - // Non-paginated siblings keep the plain arrow. - expect(out).toContain( - 'export const getOrder = (orderId: string, params: {' - ); - expect(out).not.toContain('Object.assign((orderId'); + // `listOrders` is the client method itself, which carries `.pages`/`.items` — there is + // nothing to re-wrap, and therefore no second argument shape to get wrong. + expect(out).toContain('export const { listOrders, getOrder } = client;'); + expect(out).not.toContain('Object.assign('); + expect(out).toContain('item: Order;'); }); it('grouped argsStyle needs no wrapper — properties ride along on the destructure', () => { diff --git a/packages/client-generator/src/emitters/__tests__/descriptor.test.ts b/packages/client-generator/src/emitters/__tests__/descriptor.test.ts index 14cfafc66c..9b0c5bd1c0 100644 --- a/packages/client-generator/src/emitters/__tests__/descriptor.test.ts +++ b/packages/client-generator/src/emitters/__tests__/descriptor.test.ts @@ -423,7 +423,7 @@ describe('renderDescriptors', () => { describe('renderOpsType', () => { function emitOps(model: ApiModel, extra: Partial = {}): string { const ctx: EmitContext = { - argsStyle: 'flat', + argsStyle: 'grouped', errorMode: 'throw', dateType: 'string', schemaNames: new Set(), @@ -476,14 +476,14 @@ describe('renderOpsType', () => { ); expect(out).toContain('export type Ops = {'); expect(out).toMatch( - /getOrder: \{\n {8}args: \{\n {12}orderId: string;\n {12}params\?: GetOrderParams;\n {8}\};\n {8}result: GetOrderResult;\n {4}\};/ + /getOrder: \{\n {8}args: \{\n {12}path: GetOrderPath;\n {12}query\?: GetOrderQuery;\n {8}\};\n {8}result: GetOrderResult;\n {4}\};/ ); expect(out).not.toContain('kind: "sse"'); }); it('keys args path params by wire name, quoted when not identifier-safe', () => { - // The runtime routes path values by wire name (`splitArgs` reads `args[param.name]`), - // so the args type must key them the same way — never by the sanitized ident. + // The runtime substitutes path values by wire name, so the args type must key them the + // same way — never by a sanitized ident. const out = emitOps( modelWith([ operation({ @@ -491,13 +491,16 @@ describe('renderOpsType', () => { path: '/pets/{pet-id}', pathParams: [param('pet-id', 'path', true)], }), - ]) + ]), + { schemaNames: new Set(['GetPetPath']) } ); expect(out).toContain('"pet-id": string;'); expect(out).not.toContain('pet_id'); }); it('keeps path params that sanitize to the same ident distinct via their wire names', () => { + // `schemaNames` holds the alias name, so the layer's type is inlined here and the keys + // are visible in `Ops` itself. const out = emitOps( modelWith([ operation({ @@ -505,7 +508,8 @@ describe('renderOpsType', () => { path: '/x/{a-b}/{a.b}', pathParams: [param('a-b', 'path', true), param('a.b', 'path', true)], }), - ]) + ]), + { schemaNames: new Set(['ComparePath']) } ); expect(out).toContain('"a-b": string;'); expect(out).toContain('"a.b": string;'); @@ -610,11 +614,11 @@ describe('renderOpsType', () => { ]); const out = emitOps(modelWith([listOrders, getOrder]), { pagination }); expect(out).toMatch( - /listOrders: \{\n {8}args: \{\n {12}params\?: ListOrdersParams;\n {8}\};\n {8}result: ListOrdersResult;\n {8}item: Order;\n {4}\};/ + /listOrders: \{\n {8}args: \{\n {12}query\?: ListOrdersQuery;\n {8}\};\n {8}result: ListOrdersResult;\n {8}item: Order;\n {4}\};/ ); // The non-paginated sibling stays untouched. expect(out).toMatch( - /getOrder: \{\n {8}args: \{\n {12}orderId: string;\n {8}\};\n {8}result: GetOrderResult;\n {4}\};/ + /getOrder: \{\n {8}args: \{\n {12}path: GetOrderPath;\n {8}\};\n {8}result: GetOrderResult;\n {4}\};/ ); }); @@ -643,7 +647,7 @@ describe('renderOpsType', () => { // Result mode: `result` is the envelope, so `page` carries the raw page for `.pages()`. const out = emitOps(modelWith([listOrders]), { pagination, errorMode: 'result' }); expect(out).toMatch( - /listOrders: \{\n {8}args: \{\n {12}params\?: ListOrdersParams;\n {8}\};\n {8}result: Result;\n {8}mode: "result";\n {8}item: Order;\n {8}page: ListOrdersResult;\n {4}\};/ + /listOrders: \{\n {8}args: \{\n {12}query\?: ListOrdersQuery;\n {8}\};\n {8}result: Result;\n {8}mode: "result";\n {8}item: Order;\n {8}page: ListOrdersResult;\n {4}\};/ ); // Throw mode emits no page member — `result` already IS the raw page. expect(emitOps(modelWith([listOrders]), { pagination })).not.toContain('page:'); diff --git a/packages/client-generator/src/emitters/__tests__/operation-signature.test.ts b/packages/client-generator/src/emitters/__tests__/operation-signature.test.ts index a38dcea899..de6528fc27 100644 --- a/packages/client-generator/src/emitters/__tests__/operation-signature.test.ts +++ b/packages/client-generator/src/emitters/__tests__/operation-signature.test.ts @@ -1,26 +1,24 @@ -import { operationSignature } from '../operation-signature.js'; +import { operationSignature, templatePathParams } from '../operation-signature.js'; import { operation, param } from './fixtures.js'; describe('operationSignature', () => { - it('orders path params by URL-template position and assigns unique identifiers', () => { - // Declared out of order; the path dictates order. `a-b` sanitizes to `a_b`. - const sig = operationSignature( + it('orders path params by URL-template position, keeping their wire names', () => { + // Declared out of order; the path dictates order. The wire name is the key in the + // `path` layer, so no binding identifier is derived from it any more. + const params = templatePathParams( operation({ path: '/x/{second}/y/{a-b}', pathParams: [param('a-b', 'path', true), param('second', 'path', true)], }) ); - expect(sig.pathParams.map((p) => p.param.name)).toEqual(['second', 'a-b']); - expect(sig.pathParams.map((p) => p.ident)).toEqual(['second', 'a_b']); + expect(params.map((param) => param.name)).toEqual(['second', 'a-b']); }); - it('renames a path param binding that would collide with the trailing init argument', () => { - // The wire name stays `init` (the flat sugar remaps `{ init: init_2 }`); only the - // local binding moves aside for the trailing `init: RequestOptions` parameter. - const sig = operationSignature( - operation({ path: '/x/{init}', pathParams: [param('init', 'path', true)] }) + it('drops a declared path param the template never mentions', () => { + const params = templatePathParams( + operation({ path: '/x', pathParams: [param('ghost', 'path', true)] }) ); - expect(sig.pathParams.map((p) => p.ident)).toEqual(['init_2']); + expect(params).toEqual([]); }); it('reports slot presence and hasInputs', () => { diff --git a/packages/client-generator/src/emitters/__tests__/operations.test.ts b/packages/client-generator/src/emitters/__tests__/operations.test.ts index 14e94c7323..359bf30314 100644 --- a/packages/client-generator/src/emitters/__tests__/operations.test.ts +++ b/packages/client-generator/src/emitters/__tests__/operations.test.ts @@ -1,7 +1,6 @@ -// The flat sugar signatures (`renderArgList`) and the `*` operation aliases as -// they appear in the descriptor-wired single-file client. The wiring itself (Ops, -// OPERATIONS, client, auth sugar) is covered in client-assembly.test.ts; here the -// focus is one operation's developer-facing surface. +// One operation's developer-facing surface in the descriptor-wired single-file client: +// the input shape in both styles, and the `*` aliases. The wiring itself (Ops, +// OPERATIONS, client, sugar) is covered in client-assembly.test.ts. import type { OperationModel, RequestBodyModel } from '../../intermediate-representation/model.js'; import { emitClientSingleFile } from '../client-assembly.js'; import { SCALAR, apiModel, emitWithOp, namedSchema, operation, param } from './fixtures.js'; @@ -17,26 +16,14 @@ function emitResult(op: Partial, schemas: string[] = []): string ); } -/** Throw-mode flat sugar is generic over `init` so `{ envelope: true }` narrows the return. */ -function envelopeFlatSugar( - name: string, - argsBeforeInit: string, - callArgs: string, - resultType: string, - headersType = 'Record' -): string { - const params = argsBeforeInit ? `${argsBeforeInit}, init?: I` : 'init?: I'; - const promise = `Promise>`; - return `export const ${name} = (${params}): ${promise} => client.${name}(${callArgs}, init) as ${promise};`; -} - -describe('flat sugar — argument-list permutations (renderArgList)', () => { - it('renders an operation with no inputs: only the trailing init, forwarding empty args', () => { +describe('call inputs — the namespaced shape', () => { + it('an operation with no inputs has no Variables type and is exported as a binding', () => { const out = emitWithOp({}); - expect(out).toContain(envelopeFlatSugar('op', '', '{}', 'OpResult')); + expect(out).not.toContain('OpVariables'); + expect(out).toContain('export const { op } = client;'); }); - it('orders path params by their position in the URL template, not in pathParams[]', () => { + it('groups path params under `path`, in URL-template order', () => { const out = emitWithOp({ name: 'getNested', path: '/x/{first}/y/{second}', @@ -46,61 +33,37 @@ describe('flat sugar — argument-list permutations (renderArgList)', () => { ], }); expect(out).toContain( - envelopeFlatSugar( - 'getNested', - 'first: string, second: number', - '{ first, second }', - 'GetNestedResult' - ) + 'export type GetNestedPath = {\n first: string;\n second: number;\n};' ); + expect(out).toContain('path: GetNestedPath;'); }); - it('skips path params that are declared but missing from the URL template', () => { + it('drops a path param that the URL template never mentions', () => { + // The descriptor still lists the declared parameter; the input type must not ask for + // a value that has nowhere to go in the URL. const out = emitWithOp({ path: '/x', pathParams: [param('ghost', 'path', true)] }); + expect(out).not.toContain('OpPath'); expect(out).not.toContain('ghost: string'); }); - it('sanitizes a non-identifier path param name into a safe argument, keyed by wire name', () => { + it('keys a non-identifier param by its wire name, quoted', () => { const out = emitWithOp({ name: 'getPet', path: '/pets/{pet-id}', pathParams: [param('pet-id', 'path', true)], }); - expect(out).toContain( - envelopeFlatSugar('getPet', 'pet_id: string', '{ "pet-id": pet_id }', 'GetPetResult') - ); - }); - - it('prefixes digit-leading and reserved-word path param names with `_`', () => { - expect(emitWithOp({ path: '/x/{2fa}', pathParams: [param('2fa', 'path', true)] })).toContain( - '_2fa: string' - ); - expect(emitWithOp({ path: '/x/{new}', pathParams: [param('new', 'path', true)] })).toContain( - '_new: string' - ); - }); - - it('disambiguates path param names that sanitize to the same identifier', () => { - const out = emitWithOp({ - path: '/x/{a-b}/{a.b}', - pathParams: [param('a-b', 'path', true), param('a.b', 'path', true)], - }); - expect(out).toContain('a_b: string'); - expect(out).toContain('a_b_2: string'); + expect(out).toContain('export type GetPetPath = {\n "pet-id": string;\n};'); }); - it('emits `params = {}` default when all query params are optional', () => { - const out = emitWithOp({ + it('`query` is optional when every query param is, required when one is not', () => { + const optional = emitWithOp({ queryParams: [param('q', 'query', false), param('r', 'query', false)], }); - expect(out).toMatch(/params: \{\n {4}q\?: string;\n {4}r\?: string;\n\} = \{\}/); - }); - - it('makes `params` required when at least one query param is required', () => { - const out = emitWithOp({ + expect(optional).toContain('query?: OpQuery;'); + const required = emitWithOp({ queryParams: [param('q', 'query', true), param('r', 'query', false)], }); - expect(out).toMatch(/params: \{\n {4}q: string;\n {4}r\?: string;\n\}, init/); + expect(required).toContain('query: OpQuery;'); }); it('produces `body: T` for required JSON bodies and `body?: T` for optional ones', () => { @@ -109,60 +72,46 @@ describe('flat sugar — argument-list permutations (renderArgList)', () => { schema: { kind: 'ref', name: 'Pet' }, required: true, }; - expect(emitWithOp({ requestBody: required })).toContain('body: Pet'); + const out = emitWithOp({ requestBody: required }); + expect(out).toContain('export type OpBody = Pet;'); + expect(out).toContain('body: OpBody;'); const optional: RequestBodyModel = { contentType: 'application/json', schema: SCALAR, required: false, }; - expect(emitWithOp({ requestBody: optional })).toContain('body?: string'); + expect(emitWithOp({ requestBody: optional })).toContain('body?: OpBody;'); }); - it('uses raw `FormData` for a non-object multipart body', () => { - const body: RequestBodyModel = { - contentType: 'multipart/form-data', - schema: { kind: 'unknown' }, - required: true, - }; - expect(emitWithOp({ requestBody: body })).toContain('body: FormData'); - }); - - it('uses `URLSearchParams` for urlencoded bodies', () => { - const body: RequestBodyModel = { - contentType: 'application/x-www-form-urlencoded', - schema: { kind: 'object', properties: [] }, - required: true, - }; - expect(emitWithOp({ requestBody: body })).toContain('body: URLSearchParams'); - }); - - it('uses `Blob | ArrayBuffer` for octet-stream bodies', () => { - const body: RequestBodyModel = { - contentType: 'application/octet-stream', - schema: SCALAR, - required: true, - }; - expect(emitWithOp({ requestBody: body })).toContain('body: Blob | ArrayBuffer'); + it('types a non-JSON body by its content type', () => { + const bodyOf = (contentType: string, schema: RequestBodyModel['schema']): string => + emitWithOp({ requestBody: { contentType, schema, required: true } }); + expect(bodyOf('multipart/form-data', { kind: 'unknown' })).toContain( + 'export type OpBody = FormData;' + ); + expect( + bodyOf('application/x-www-form-urlencoded', { kind: 'object', properties: [] }) + ).toContain('export type OpBody = URLSearchParams;'); + expect(bodyOf('application/octet-stream', SCALAR)).toContain( + 'export type OpBody = Blob | ArrayBuffer;' + ); }); - it('emits header params as a typed `headers` slot, forwarded to the client method', () => { - const out = emitWithOp({ + it('groups header params under `headers`, optional when all of them are', () => { + const required = emitWithOp({ name: 'getThing', headerParams: [param('X-Api-Version', 'header', true)], }); - expect(out).toMatch(/headers: \{\n {4}"X-Api-Version": string;\n\}, init/); - expect(out).toContain('=> client.getThing({ headers }, init) as Promise<'); - }); - - it('defaults the `headers` slot to `= {}` when all header params are optional', () => { - const out = emitWithOp({ + expect(required).toContain('export type GetThingHeaders = {\n "X-Api-Version": string;\n};'); + expect(required).toContain('headers: GetThingHeaders;'); + const optional = emitWithOp({ name: 'getThing', headerParams: [param('X-Trace', 'header', false)], }); - expect(out).toMatch(/headers: \{\n {4}"X-Trace"\?: string;\n\} = \{\}/); + expect(optional).toContain('headers?: GetThingHeaders;'); }); - it('renders per-param JSDoc (description + schema metadata) above sugar params', () => { + it('renders per-param JSDoc (description + schema metadata)', () => { const out = emitWithOp({ name: 'listPets', queryParams: [ @@ -178,7 +127,7 @@ describe('flat sugar — argument-list permutations (renderArgList)', () => { expect(out).toMatch(/Page size\.[\s\S]*@minimum 1[\s\S]*@maximum 100[\s\S]*limit\?: number;/); }); - it('the SSE sugar takes `SseOptions`; regular ops take `RequestOptions`', () => { + it('exports one binding per operation — SSE included, with no wrapper in sight', () => { const out = emitClientSingleFile( apiModel({ services: [ @@ -198,10 +147,74 @@ describe('flat sugar — argument-list permutations (renderArgList)', () => { ], }) ); + expect(out).toContain('export const { streamMessages, listThings } = client;'); + expect(out).not.toContain('=> client.streamMessages('); + }); +}); + +describe('call inputs — the merged shape (argsStyle: flat)', () => { + /** Emit a flat-style client whose only operation is `operation(op)`. */ + function emitFlat(op: Partial): string { + return emitClientSingleFile( + apiModel({ services: [{ name: 'Default', operations: [operation(op)] }] }), + { argsStyle: 'flat' } + ); + } + + it('puts every parameter at one level and intersects a required object body', () => { + const out = emitFlat({ + name: 'updateThing', + path: '/things/{id}', + pathParams: [param('id', 'path', true)], + queryParams: [param('dryRun', 'query', false, { kind: 'scalar', scalar: 'boolean' })], + requestBody: { + contentType: 'application/json', + schema: { + kind: 'object', + properties: [{ name: 'status', schema: SCALAR, required: true }], + }, + required: true, + }, + }); expect(out).toContain( - 'export const streamMessages = (init: SseOptions = {}) => client.streamMessages({}, init);' + 'export type UpdateThingVariables = {\n id: string;\n dryRun?: boolean;\n} & UpdateThingBody;' ); - expect(out).toContain(envelopeFlatSugar('listThings', '', '{}', 'ListThingsResult')); + // The client is told which shape its types promise, so the runtime converts before use. + expect(out).toContain('argsStyle: "flat"'); + }); + + it('keeps the `body` key for a body a merged call cannot spread', () => { + const out = emitFlat({ + name: 'upload', + requestBody: { contentType: 'application/octet-stream', schema: SCALAR, required: true }, + }); + expect(out).toContain('export type UploadVariables = {\n body: UploadBody;\n};'); + }); + + it('an optional body stays a `body` key: omitting it must differ from omitting its fields', () => { + const out = emitFlat({ + name: 'patchThing', + requestBody: { + contentType: 'application/json', + schema: { + kind: 'object', + properties: [{ name: 'status', schema: SCALAR, required: true }], + }, + required: false, + }, + }); + expect(out).toContain('body?: PatchThingBody;'); + }); + + it('falls back to the namespaced shape when one name lands in two layers', () => { + const out = emitFlat({ + name: 'getThing', + path: '/things/{id}', + pathParams: [param('id', 'path', true)], + queryParams: [param('id', 'query', false)], + }); + expect(out).toContain('path: GetThingPath;'); + expect(out).toContain('query?: GetThingQuery;'); }); }); @@ -220,7 +233,7 @@ describe('operation type aliases (*Result / *Params / *Body / *Headers / *Variab expect(out).toContain('export type GetPetResult = Pet;'); }); - it('emits *Params/*Body/*Headers/*Variables per input kind, in a stable order', () => { + it('emits *Path/*Query/*Body/*Headers/*Variables per input kind, in a stable order', () => { const out = emitWithOp({ name: 'updateOrder', path: '/orders/{orderId}', @@ -238,7 +251,8 @@ describe('operation type aliases (*Result / *Params / *Body / *Headers / *Variab }); const names = [ 'UpdateOrderResult', - 'UpdateOrderParams', + 'UpdateOrderPath', + 'UpdateOrderQuery', 'UpdateOrderBody', 'UpdateOrderHeaders', 'UpdateOrderVariables', @@ -250,7 +264,7 @@ describe('operation type aliases (*Result / *Params / *Body / *Headers / *Variab last = idx; } expect(out).toMatch( - /export type UpdateOrderVariables = \{[\s\S]*orderId: string;[\s\S]*params\?: UpdateOrderParams;[\s\S]*body: UpdateOrderBody;[\s\S]*headers\?: UpdateOrderHeaders;[\s\S]*\};/ + /export type UpdateOrderVariables = \{[\s\S]*path: UpdateOrderPath;[\s\S]*query\?: UpdateOrderQuery;[\s\S]*body: UpdateOrderBody;[\s\S]*headers\?: UpdateOrderHeaders;[\s\S]*\};/ ); }); diff --git a/packages/client-generator/src/emitters/__tests__/swr.test.ts b/packages/client-generator/src/emitters/__tests__/swr.test.ts index addf0b6031..7c9fac8770 100644 --- a/packages/client-generator/src/emitters/__tests__/swr.test.ts +++ b/packages/client-generator/src/emitters/__tests__/swr.test.ts @@ -3,16 +3,16 @@ import { apiModel, namedSchema, operation, param, SCALAR } from './fixtures.js'; const SDK = './client.js'; -function render(ops: Parameters[0][], argsStyle: 'flat' | 'grouped' = 'grouped') { +function render(ops: Parameters[0][]) { return renderSwrModule( apiModel({ services: [{ name: 'Default', operations: ops.map(operation) }] }), - { sdkModule: SDK, argsStyle } + { sdkModule: SDK } ); } describe('renderSwrModule', () => { it('returns empty string when the model has no operations', () => { - expect(renderSwrModule(apiModel(), { sdkModule: SDK, argsStyle: 'flat' })).toBe(''); + expect(renderSwrModule(apiModel(), { sdkModule: SDK })).toBe(''); }); it('skips SSE operations (not exported by the sdk) and wraps only the regular ones', () => { @@ -53,7 +53,7 @@ describe('renderSwrModule', () => { }, ], }), - { sdkModule: SDK, argsStyle: 'grouped' } + { sdkModule: SDK } ); expect(out).not.toContain('useGetUser'); expect(out).toContain('useListUsers'); @@ -106,13 +106,6 @@ describe('renderSwrModule', () => { 'return useSWR(listPetsKey(), () => listPets({}, { ...init, envelope: undefined }));' ); }); - - it('flat style: the no-input sugar takes the init directly', () => { - const out = render([{ name: 'listPets', method: 'get', path: '/pets' }], 'flat'); - expect(out).toContain( - 'return useSWR(listPetsKey(), () => listPets({ ...init, envelope: undefined }));' - ); - }); }); describe('mutation operation (POST) with a body', () => { @@ -140,41 +133,33 @@ describe('renderSwrModule', () => { }); }); - describe('flat forwarding', () => { - it('query: spreads vars., vars.params, then init (URL-template order)', () => { - const out = render( - [ - { - name: 'getPet', - method: 'get', - path: '/pets/{petId}', - pathParams: [param('petId', 'path', true)], - queryParams: [param('expand', 'query', false)], - }, - ], - 'flat' - ); - expect(out).toContain( - '() => getPet(vars.petId, vars.params, { ...init, envelope: undefined })' - ); + describe('input forwarding', () => { + it('forwards the whole input object, whatever shape the sdk takes', () => { + const out = render([ + { + name: 'getPet', + method: 'get', + path: '/pets/{petId}', + pathParams: [param('petId', 'path', true)], + queryParams: [param('expand', 'query', false)], + }, + ]); + expect(out).toContain('() => getPet(vars, { ...init, envelope: undefined })'); }); - it('mutation: spreads arg. (URL-template order), then params, body, headers', () => { - const out = render( - [ - { - name: 'replace', - method: 'put', - path: '/a/{a}/b/{b}', - pathParams: [param('b', 'path', true), param('a', 'path', true)], - queryParams: [param('q', 'query', false)], - requestBody: { contentType: 'application/json', schema: SCALAR, required: true }, - headerParams: [param('X-Trace', 'header', false)], - }, - ], - 'flat' - ); - expect(out).toContain('=> replace(arg.a, arg.b, arg.params, arg.body, arg.headers)'); + it('a mutation trigger forwards its `arg` the same way', () => { + const out = render([ + { + name: 'replace', + method: 'put', + path: '/a/{a}/b/{b}', + pathParams: [param('b', 'path', true), param('a', 'path', true)], + queryParams: [param('q', 'query', false)], + requestBody: { contentType: 'application/json', schema: SCALAR, required: true }, + headerParams: [param('X-Trace', 'header', false)], + }, + ]); + expect(out).toContain('}) => replace(arg));'); }); }); diff --git a/packages/client-generator/src/emitters/__tests__/tanstack-query.test.ts b/packages/client-generator/src/emitters/__tests__/tanstack-query.test.ts index ae56f1cc15..b44d5f6509 100644 --- a/packages/client-generator/src/emitters/__tests__/tanstack-query.test.ts +++ b/packages/client-generator/src/emitters/__tests__/tanstack-query.test.ts @@ -229,9 +229,9 @@ describe('renderTanstackModule', () => { ); expect(out).toContain('queryKey: [...listOrdersQueryKey(vars), "infinite"] as const'); expect(out).toContain( - 'queryFn: ({ pageParam, signal }) => instance.listOrders({ ...vars, params: { ...vars.params, after: pageParam } }, { ...init, signal, envelope: undefined })' + 'queryFn: ({ pageParam, signal }) => instance.listOrders({ ...vars, query: { ...vars.query, after: pageParam } }, { ...init, signal, envelope: undefined })' ); - expect(out).toContain('initialPageParam: vars.params?.after'); + expect(out).toContain('initialPageParam: vars.query?.after'); expect(out).toContain('if (lastPage.page?.hasNextPage === false)'); expect(out).toContain('const next = lastPage.page?.endCursor;'); // The cursor is a nullable string reached through an optional chain: all three stops. @@ -291,7 +291,7 @@ describe('renderTanstackModule', () => { const out = render([offsetOp], { pagination: { style: 'offset', offsetParam: 'offset', items: '/items' }, }); - expect(out).toContain('initialPageParam: vars.params?.offset ?? 0'); + expect(out).toContain('initialPageParam: vars.query?.offset ?? 0'); expect(out).toContain('getNextPageParam: (lastPage, _allPages, lastPageParam) => {'); expect(out).toContain('const count = lastPage.items?.length ?? 0;'); expect(out).toContain('return count === 0 ? undefined : lastPageParam + count;'); @@ -301,7 +301,7 @@ describe('renderTanstackModule', () => { const out = render([offsetOp], { pagination: { style: 'page', offsetParam: 'offset', items: '/items' }, }); - expect(out).toContain('initialPageParam: vars.params?.offset ?? 1'); + expect(out).toContain('initialPageParam: vars.query?.offset ?? 1'); expect(out).toContain('return count === 0 ? undefined : lastPageParam + 1;'); }); diff --git a/packages/client-generator/src/emitters/cli.ts b/packages/client-generator/src/emitters/cli.ts index b00457565c..6c2bf0f1ed 100644 --- a/packages/client-generator/src/emitters/cli.ts +++ b/packages/client-generator/src/emitters/cli.ts @@ -15,6 +15,7 @@ import { groupSlug, type CliAuthScheme, type CliCommand, type CliFlag } from '.. import { HEADER } from './emit-options.js'; import { embedCliRuntime } from './inline-runtime.js'; import { resolveOperationPagination, type PaginationConfig } from './pagination.js'; +import { flatInputShape } from './render-client.js'; import { isSseOp } from './sse.js'; function kebab(name: string): string { @@ -59,10 +60,24 @@ function jsonSuccessSchema(op: OperationModel): SchemaModel | undefined { ?.schema; } +/** + * Whether a flat-style call spells this operation's body as its own properties — the same + * decision the client's types make, so the dispatcher never has to guess from a value. + */ +function mergedBodyFlag( + op: OperationModel, + model: ApiModel, + argsStyle: 'grouped' | 'flat' | undefined +): { merged?: true } { + if (argsStyle !== 'flat') return {}; + const shape = flatInputShape(op, model.schemas); + return 'mergeBody' in shape && shape.mergeBody ? { merged: true } : {}; +} + /** Every operation as pure command data — the table `runCli` interprets. */ export function commandData( model: ApiModel, - emit: { pagination?: PaginationConfig } + emit: { pagination?: PaginationConfig; argsStyle?: 'grouped' | 'flat' } ): CliCommand[] { const commands: CliCommand[] = []; for (const service of model.services) { @@ -83,7 +98,9 @@ export function commandData( ...(param.description !== undefined ? { description: param.description } : {}), })), flags: op.queryParams.map(flagFor), - ...(jsonBody ? { body: { required: jsonBody.required } } : {}), + ...(jsonBody + ? { body: { required: jsonBody.required, ...mergedBodyFlag(op, model, emit.argsStyle) } } + : {}), ...(jsonBody === undefined && op.requestBody !== undefined ? { unsupportedBody: op.requestBody.contentType } : {}), @@ -137,6 +154,8 @@ export type CliModuleOptions = { zodSelected: boolean; binName: string; pagination?: PaginationConfig; + /** The sibling client's call shape, which the dispatcher builds its inputs for. */ + argsStyle?: 'grouped' | 'flat'; }; /** @@ -178,7 +197,10 @@ function warnShadowedCommands(commands: CliCommand[]): void { /** The whole `.cli.ts` file. */ export function renderCliModule(model: ApiModel, options: CliModuleOptions): string { - const commands = commandData(model, { pagination: options.pagination }); + const commands = commandData(model, { + pagination: options.pagination, + argsStyle: options.argsStyle, + }); warnShadowedCommands(commands); const schemes = cliAuthSchemes(model); const clientModule = `./${options.stem}.${options.importExt}`; @@ -212,7 +234,7 @@ export function renderCliModule(model: ApiModel, options: CliModuleOptions): str `export const wiring: CliWiring = { binName: ${codeJson(options.binName)}, client, - configure, +${options.argsStyle === 'flat' ? ' argsStyle: "flat",\n' : ''} configure, schemes: ${codeJson(schemes)}, env: process.env, stdin: () => readFileSync(0, "utf-8"), diff --git a/packages/client-generator/src/emitters/client-assembly.ts b/packages/client-generator/src/emitters/client-assembly.ts index 263fe28ae2..d6e6b63e18 100644 --- a/packages/client-generator/src/emitters/client-assembly.ts +++ b/packages/client-generator/src/emitters/client-assembly.ts @@ -22,12 +22,7 @@ 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, - renderFlatSugar, - renderOpsType, -} from './render-client.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'; @@ -66,14 +61,13 @@ function emitClient( // before any statement is built — one aggregated error for the whole model. const pagination = resolveModelPagination(model, options.pagination); const ctx: EmitContext = { - argsStyle: options.argsStyle ?? 'flat', + argsStyle: options.argsStyle ?? 'grouped', errorMode: options.errorMode ?? 'throw', dateType: options.dateType ?? 'string', schemaNames: new Set(model.schemas.map((s) => s.name)), schemas: model.schemas, pagination, }; - const flat = ctx.argsStyle === 'flat'; const hasSse = ops.some(isSseOp); const hasRegular = ops.some((op) => !isSseOp(op)); @@ -99,21 +93,17 @@ function emitClient( setup: !!options.setup, paginate: pagination.size > 0, }) - : importLine(options, ctx, { - hasFlatSse: hasSse && flat, - hasFlatRegular: hasRegular && flat, - hasRegular, - }); + : importLine(options, ctx, { hasRegular }); const schemaSection = [ renderTypeAliases(model.schemas, ctx.dateType), renderTypeGuards(model.schemas), ] .filter((section) => section.length > 0) .join('\n\n'); - const bodySection = [...ops.map((op) => renderAliases(op, ctx, 'wire')), ...wiring] + const bodySection = [...ops.map((op) => renderAliases(op, ctx)), ...wiring] .filter((section) => section.length > 0) .join('\n\n'); - const sugar = sugarSection(ops, idents, ctx); + 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); @@ -169,23 +159,14 @@ function schemaLinks(model: ApiModel, ctx: EmitContext, specifier: string): stri } /** The single import from the runtime package — only names the file actually references. */ -function importLine( - options: EmitOptions, - ctx: EmitContext, - refs: { hasFlatSse: boolean; hasFlatRegular: boolean; hasRegular: boolean } -): string { +function importLine(options: EmitOptions, ctx: EmitContext, refs: { hasRegular: boolean }): string { const values = ['createClient', ...(options.setup ? ['mergeSetup'] : [])]; const types = [ ...(options.setup ? ['ClientConfig', 'Middleware'] : []), 'OperationDescriptor', - // Flat sugar signatures reference the per-call option types. - ...(refs.hasFlatRegular ? ['RequestOptions'] : []), - // Flat throw-mode sugar return types vary with the inferred request-option type. - ...(refs.hasFlatRegular && ctx.errorMode !== 'result' ? ['EnvelopeResult'] : []), // `Ops` wraps results in `Result` in result mode — but only NON-SSE members // (an SSE-only spec would otherwise import it unused and fail noUnusedLocals). ...(ctx.errorMode === 'result' && refs.hasRegular ? ['Result'] : []), - ...(refs.hasFlatSse ? ['SseOptions'] : []), ].sort(); const names = [...values, ...types.map((t) => `type ${t}`)].join(', '); return `import { ${names} } from '${PACKAGE_SPECIFIER}';`; @@ -199,6 +180,9 @@ function clientSection(options: EmitOptions, ctx: EmitContext, model: ApiModel): // relative URL, which Node's fetch rejects. ...(serverUrl !== undefined ? [`serverUrl: ${codeString(serverUrl)}`] : []), ...(ctx.errorMode === 'result' ? ['errorMode: "result"'] : []), + // The runtime converts a merged call to the namespaced shape, so it has to know + // which style this module's types promise. + ...(ctx.argsStyle === 'flat' ? ['argsStyle: "flat"'] : []), // Client identification for API-owner telemetry; the runtime sends it only // outside browsers, and `configure({ clientHeader: false })` disables it. 'clientHeader: "redocly-client-generator"', @@ -230,23 +214,17 @@ function clientSection(options: EmitOptions, ctx: EmitContext, model: ApiModel): } /** Core destructure + per-scheme auth setters + per-operation call sugar. */ -function sugarSection( - ops: OperationModel[], - idents: Map, - ctx: EmitContext -): string { +function sugarSection(ops: OperationModel[], idents: Map): string { // Credentials go through `configure({ auth })` or `client.auth.*` — one way per act. // Per-scheme setters used to be exported here too, which gave the same act three // spellings and a name per scheme that operation names then had to avoid. const lines = ['export const { configure, use } = client;']; if (ops.length === 0) return lines.join('\n'); - if (ctx.argsStyle === 'grouped') { - // Grouped style: the client methods already take the grouped args shape. - const names = ops.map((op) => idents.get(op.name)!).join(', '); - lines.push(`export const { ${names} } = client;`); - return lines.join('\n'); - } - for (const op of ops) lines.push(renderFlatSugar(op, idents.get(op.name)!, ctx)); + // Bindings, never wrappers: `updateOrder` IS `client.updateOrder`, so importing the name + // and reaching through the instance cannot disagree about the arguments. `argsStyle` + // shapes the method itself, which is why one binding serves both styles. + const names = ops.map((op) => idents.get(op.name)!).join(', '); + lines.push(`export const { ${names} } = client;`); return lines.join('\n'); } diff --git a/packages/client-generator/src/emitters/operation-signature.ts b/packages/client-generator/src/emitters/operation-signature.ts index e7e9693a26..fe9224a95e 100644 --- a/packages/client-generator/src/emitters/operation-signature.ts +++ b/packages/client-generator/src/emitters/operation-signature.ts @@ -1,53 +1,47 @@ -// The shared calling-convention description for an operation. Both the sdk (which -// emits each operation's parameter list) and the wrapper generators (which emit the -// forwarding call) derive their argument *order*, slot presence, and `Variables` -// naming from this one source — so a flat-mode signature and its call site can never drift. +// The shared calling-convention description for an operation. The sdk (which emits each +// operation's input type) and the wrapper generators (which forward it) read slot presence +// and `Variables` naming from this one source, so a call and its type cannot drift. import type { OperationModel, ParamModel } from '../intermediate-representation/model.js'; -import { uniqueIdent } from './identifier.js'; import { pascalCase } from './support.js'; -/** A path parameter paired with the unique JS identifier used for it in flat mode. */ -export type SignaturePathParam = { param: ParamModel; ident: string }; - export type OperationSignature = { - /** Path params in URL-template order, each with its unique JS identifier. */ - pathParams: SignaturePathParam[]; - /** Slot presence, in the order flat-mode arguments follow the path params. */ + /** Slot presence — which input layers the operation has. */ hasQuery: boolean; hasBody: boolean; hasHeaders: boolean; hasCookies: boolean; /** Any input at all — i.e. a `Variables` type exists for the operation. */ hasInputs: boolean; - /** Grouped mode: whether `vars` is required (else it defaults to `= {}`). */ + /** Whether the input argument is required (else it defaults to `= {}`). */ varsRequired: boolean; /** The `Variables` type-alias name. */ variablesTypeName: string; }; -/** Compute the calling-convention description for `op`. Pure; no AST. */ -export function operationSignature(op: OperationModel): OperationSignature { - const byName = new Map(op.pathParams.map((p) => [p.name, p] as const)); +/** + * Path parameters in URL-template order — the order a reader sees them in the path, and the + * order the old positional signature used. A parameter declared but absent from the template + * is dropped: it has nowhere to go in the URL, so asking for a value would mislead. + */ +export function templatePathParams(op: OperationModel): ParamModel[] { + const byName = new Map(op.pathParams.map((param) => [param.name, param] as const)); const ordered: ParamModel[] = []; for (const match of op.path.matchAll(/\{([^{}]+)\}/g)) { - const p = byName.get(match[1]); - if (p) ordered.push(p); + const param = byName.get(match[1]); + if (param !== undefined) ordered.push(param); } - // Seed the slot/`init` argument names the flat signature appends after the path - // params: a same-named path param keeps its wire name but binds as `_2` - // (the sugar remaps `{ : }`). The slot names themselves are - // rejected at build time (`assertPathParamsAvoidArgSlots`); `init` is only a - // binding here, so the remap fully handles it. - const used = new Set(['params', 'body', 'headers', 'cookies', 'init']); - const pathParams = ordered.map((param) => ({ param, ident: uniqueIdent(param.name, used) })); + return ordered; +} +/** Compute the calling-convention description for `op`. Pure; no AST. */ +export function operationSignature(op: OperationModel): OperationSignature { + const pathParams = templatePathParams(op); const hasQuery = op.queryParams.length > 0; const hasBody = Boolean(op.requestBody); const hasHeaders = op.headerParams.length > 0; const hasCookies = op.cookieParams.length > 0; return { - pathParams, hasQuery, hasBody, hasHeaders, diff --git a/packages/client-generator/src/emitters/render-client.ts b/packages/client-generator/src/emitters/render-client.ts index ff2d94bc75..cc1cba5323 100644 --- a/packages/client-generator/src/emitters/render-client.ts +++ b/packages/client-generator/src/emitters/render-client.ts @@ -5,14 +5,15 @@ import { allOperations, type ApiModel, + type NamedSchemaModel, type OperationModel, type ParamModel, type RequestBodyModel, type ResponseBodyModel, type SchemaModel, } from '../intermediate-representation/model.js'; -import { isIdentifier, safeIdent } from './identifier.js'; -import { operationSignature } from './operation-signature.js'; +import { safeIdent } from './identifier.js'; +import { operationSignature, templatePathParams } from './operation-signature.js'; import { isTypedMultipart } from './operation-types.js'; import type { EmitContext } from './operations.js'; import { responseHeadersTypeText } from './response-headers.js'; @@ -118,31 +119,101 @@ function inputPropLine( return `${indent}${key}${required ? '' : '?'}: ${type};`; } +/** The named schema a `ref` chain ends at, for deciding whether a body can merge. */ +function resolvedSchema( + schema: SchemaModel, + schemas: readonly NamedSchemaModel[] | undefined +): SchemaModel | undefined { + const seen = new Set(); + let current = schema; + while (current.kind === 'ref') { + const { name } = current; + if (seen.has(name)) return undefined; + seen.add(name); + const named = schemas?.find((candidate) => candidate.name === name); + if (named === undefined) return undefined; + current = named.schema; + } + return current; +} + +/** + * How a flat-style call spells one operation's inputs. Every parameter sits at one level, + * and a REQUIRED object body contributes its own properties — an optional body cannot + * (omitting it and omitting its required properties would look the same), and neither can + * an array, scalar, or binary body, so those keep the `body` key. + * + * When one name appears in two layers (a path and a query parameter of the same name, which + * OpenAPI permits) a merged call cannot say which is which, so that operation keeps the + * namespaced shape. The caller reports it once. + */ +export function flatInputShape( + op: OperationModel, + schemas: readonly NamedSchemaModel[] | undefined +): { mergeBody: boolean } | { collisions: string[] } { + const params = [ + ...templatePathParams(op), + ...op.queryParams, + ...op.headerParams, + ...op.cookieParams, + ]; + const resolved = op.requestBody ? resolvedSchema(op.requestBody.schema, schemas) : undefined; + const mergeBody = + (op.requestBody?.required ?? false) && + (resolved?.kind === 'object' || resolved?.kind === 'intersection'); + const counts = new Map(); + for (const param of params) counts.set(param.name, (counts.get(param.name) ?? 0) + 1); + // An unmerged body keeps the `body` key, which a parameter of that name would shadow. + if (op.requestBody && !mergeBody) counts.set('body', (counts.get('body') ?? 0) + 1); + if (mergeBody && resolved.kind === 'object') { + for (const property of resolved.properties) { + counts.set(property.name, (counts.get(property.name) ?? 0) + 1); + } + } + const collisions = [...counts].filter(([, count]) => count > 1).map(([paramName]) => paramName); + return collisions.length > 0 ? { collisions } : { mergeBody }; +} + +/** `: ` lines for parameters written at one level (the merged, flat shape). */ +function mergedParamLines(params: ParamModel[], ctx: EmitContext, inner: string): string[] { + return params.flatMap((param) => [ + ...tsJsdoc(param.description, param.schema.metadata, inner), + `${inner}${safeIdent(param.name)}${param.required ? '' : '?'}: ${tsType(param.schema, ctx.dateType, inner)};`, + ]); +} + /** The `Variables` object type literal (see operation-aliases.ts for the contract). */ export function variablesTypeText( op: OperationModel, name: string, - orderedPathParams: ParamModel[], - pathParamIdent: Map, ctx: EmitContext, - pathKeys: 'ident' | 'wire', indent = '' ): string { const { dateType, schemaNames } = ctx; const inner = indent + INDENT; + const flat = ctx.argsStyle === 'flat' ? flatInputShape(op, ctx.schemas) : undefined; + if (flat !== undefined && 'mergeBody' in flat) { + return mergedVariablesText(op, name, ctx, flat.mergeBody, indent); + } const lines: string[] = []; - for (const param of orderedPathParams) { - const key = pathKeys === 'wire' ? safeIdent(param.name) : pathParamIdent.get(param.name)!; + const pathParams = templatePathParams(op); + if (pathParams.length > 0) { lines.push( - ...tsJsdoc(param.description, param.schema.metadata, inner), - `${inner}${key}: ${tsType(param.schema, dateType, inner)};` + inputPropLine( + 'path', + `${name}Path`, + () => paramsTypeText(pathParams, dateType, inner), + true, + schemaNames, + inner + ) ); } if (op.queryParams.length > 0) { lines.push( inputPropLine( - 'params', - `${name}Params`, + 'query', + `${name}Query`, () => paramsTypeText(op.queryParams, dateType, inner), op.queryParams.some((p) => p.required), schemaNames, @@ -189,6 +260,44 @@ export function variablesTypeText( return lines.length === 0 ? '{}' : `{\n${lines.join('\n')}\n${indent}}`; } +/** + * The merged (`argsStyle: flat`) `Variables`: every parameter at one level, intersected + * with the body alias when the body merges. Intersecting reuses the `Body` alias rather + * than reprinting its properties, so one body type stays one type. + */ +function mergedVariablesText( + op: OperationModel, + name: string, + ctx: EmitContext, + mergeBody: boolean, + indent: string +): string { + const inner = indent + INDENT; + const lines = mergedParamLines( + [...templatePathParams(op), ...op.queryParams, ...op.headerParams, ...op.cookieParams], + ctx, + inner + ); + if (op.requestBody && !mergeBody) { + lines.push( + inputPropLine( + 'body', + `${name}Body`, + () => bodyTypeText(op.requestBody!, ctx.dateType, inner), + op.requestBody.required, + ctx.schemaNames, + inner + ) + ); + } + const bodyRef = ctx.schemaNames.has(`${name}Body`) + ? bodyTypeText(op.requestBody!, ctx.dateType, indent) + : `${name}Body`; + const object = lines.length === 0 ? '{}' : `{\n${lines.join('\n')}\n${indent}}`; + if (!mergeBody) return object; + return lines.length === 0 ? bodyRef : `${object} & ${bodyRef}`; +} + /** The raw success ref: the `Result` alias, or the inline type when that name collides. */ function rawResultText(op: OperationModel, ctx: EmitContext, indent: string): string { const resultName = `${pascalCase(op.name)}Result`; @@ -217,17 +326,8 @@ export function renderOpsType( const memberBlocks = ops.flatMap((op) => { const ident = idents.get(op.name)!; const name = pascalCase(op.name); - const { pathParams } = operationSignature(op); const inner = INDENT + INDENT; - const args = variablesTypeText( - op, - name, - pathParams.map((p) => p.param), - new Map(pathParams.map((p) => [p.param.name, p.ident])), - ctx, - 'wire', - inner - ); + const args = variablesTypeText(op, name, ctx, inner); const sse = isSseOp(op); const result = sse ? sseEventText(op, ctx.dateType, inner) @@ -267,16 +367,12 @@ export function renderOpsType( ].join('\n'); } -/** One operation's `*` aliases (Result/Error/Params/Body/Headers/Cookies/Variables), collision-suppressed. */ -export function renderAliases( - op: OperationModel, - ctx: EmitContext, - pathKeys: 'ident' | 'wire' -): string { +/** One operation's `*` aliases (Result/Error/Path/Query/Body/Headers/Cookies/Variables), collision-suppressed. */ +export function renderAliases(op: OperationModel, ctx: EmitContext): string { const { dateType, schemaNames } = ctx; const name = pascalCase(op.name); const sse = isSseOp(op); - const { pathParams, hasInputs } = operationSignature(op); + const { hasInputs } = operationSignature(op); const blocks: string[] = []; if (!sse) { @@ -294,8 +390,12 @@ export function renderAliases( } } } - if (op.queryParams.length > 0 && !schemaNames.has(`${name}Params`)) { - blocks.push(`export type ${name}Params = ${paramsTypeText(op.queryParams, dateType)};`); + const pathParams = templatePathParams(op); + if (pathParams.length > 0 && !schemaNames.has(`${name}Path`)) { + blocks.push(`export type ${name}Path = ${paramsTypeText(pathParams, dateType)};`); + } + if (op.queryParams.length > 0 && !schemaNames.has(`${name}Query`)) { + blocks.push(`export type ${name}Query = ${paramsTypeText(op.queryParams, dateType)};`); } if (op.requestBody && !schemaNames.has(`${name}Body`)) { blocks.push(`export type ${name}Body = ${bodyTypeText(op.requestBody, dateType)};`); @@ -314,101 +414,11 @@ export function renderAliases( blocks.push(`export type ${name}Cookies = ${paramsTypeText(op.cookieParams, dateType)};`); } if (hasInputs && !schemaNames.has(`${name}Variables`)) { - const variables = variablesTypeText( - op, - name, - pathParams.map((p) => p.param), - new Map(pathParams.map((p) => [p.param.name, p.ident])), - ctx, - pathKeys - ); - blocks.push(`export type ${name}Variables = ${variables};`); + blocks.push(`export type ${name}Variables = ${variablesTypeText(op, name, ctx)};`); } return blocks.join('\n\n'); } -/** The flat sugar's parameter list (path args, slots, trailing `init`), as text. */ -function argListText( - op: OperationModel, - orderedPathParams: ParamModel[], - pathParamIdent: Map, - ctx: EmitContext, - initParam: string -): string { - const { dateType } = ctx; - const args: string[] = orderedPathParams.map( - (param) => `${pathParamIdent.get(param.name)!}: ${tsType(param.schema, dateType)}` - ); - const slot = (name: string, params: ParamModel[]) => - `${name}: ${paramsTypeText(params, dateType)}${params.some((p) => p.required) ? '' : ' = {}'}`; - if (op.queryParams.length > 0) args.push(slot('params', op.queryParams)); - if (op.requestBody) { - args.push( - `body${op.requestBody.required ? '' : '?'}: ${bodyTypeText(op.requestBody, dateType)}` - ); - } - if (op.headerParams.length > 0) args.push(slot('headers', op.headerParams)); - if (op.cookieParams.length > 0) args.push(slot('cookies', op.cookieParams)); - args.push(initParam); - return args.join(', '); -} - -/** The envelope headers type argument: alias, inline literal on collision, or the empty bag. */ -function flatHeadersText(op: OperationModel, ctx: EmitContext): string { - const headers = op.successResponseHeaders; - if (!headers || headers.length === 0) return 'Record'; - const alias = `${pascalCase(op.name)}ResponseHeaders`; - return ctx.schemaNames.has(alias) ? responseHeadersTypeText(headers, ctx.schemas) : alias; -} - -/** One flat one-liner: the positional signature forwarding to the grouped client method. */ -export function renderFlatSugar(op: OperationModel, ident: string, ctx: EmitContext): string { - const sse = isSseOp(op); - // Throw-mode (non-SSE) sugar is generic over `init` so `{ envelope: true }` narrows - // the return type to `Envelope<…>` (plain `RequestOptions` would collapse it). - const envelopeAware = !sse && ctx.errorMode !== 'result'; - const { pathParams } = operationSignature(op); - const params = argListText( - op, - pathParams.map((p) => p.param), - new Map(pathParams.map((p) => [p.param.name, p.ident])), - ctx, - envelopeAware ? 'init?: I' : `init: ${sse ? 'SseOptions' : 'RequestOptions'} = {}` - ); - const props: string[] = pathParams.map(({ param, ident: paramIdent }) => - param.name === paramIdent - ? paramIdent - : `${isIdentifier(param.name) ? param.name : JSON.stringify(param.name)}: ${paramIdent}` - ); - if (op.queryParams.length > 0) props.push('params'); - if (op.requestBody) props.push('body'); - if (op.headerParams.length > 0) props.push('headers'); - if (op.cookieParams.length > 0) props.push('cookies'); - const args = props.length === 0 ? '{}' : `{ ${props.join(', ')} }`; - const fn = envelopeAware - ? (() => { - const promise = `Promise>`; - return `(${params}): ${promise} => client.${ident}(${args}, init) as ${promise}`; - })() - : `(${params}) => client.${ident}(${args}, init)`; - if (!ctx.pagination?.has(op.name)) return `export const ${ident} = ${fn};`; - // The iterators take the SAME flat arguments as the call above. Binding the client - // method's grouped iterators here would give one exported function two argument - // shapes — `listOrders({ limit: 20 })` beside `listOrders.pages({ params: { limit: 20 } })`. - // `init` is a plain `RequestOptions`: `envelope` has no meaning for an iterator. - const iterParams = argListText( - op, - pathParams.map((p) => p.param), - new Map(pathParams.map((p) => [p.param.name, p.ident])), - ctx, - 'init: RequestOptions = {}' - ); - const iterators = ['pages', 'items'] - .map((kind) => `${kind}: (${iterParams}) => client.${ident}.${kind}(${args}, init)`) - .join(', '); - return `export const ${ident} = Object.assign(${fn}, { ${iterators} });`; -} - /** * Schema names the ENTRY file's own types reference — the split layout's type-only * import list. Derived from the IR (the exact sources the alias/Ops renderers type): diff --git a/packages/client-generator/src/emitters/runtime-sources.ts b/packages/client-generator/src/emitters/runtime-sources.ts index 1b9de73cf6..683ceb4a95 100644 --- a/packages/client-generator/src/emitters/runtime-sources.ts +++ b/packages/client-generator/src/emitters/runtime-sources.ts @@ -1,7 +1,7 @@ // GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`. export const RUNTIME_SOURCES = { 'types.ts': - "/**\n * The public type surface of the client runtime — `@redocly/client-generator`'s\n * app-facing runtime module. Pure types, no runtime code (excluded from coverage).\n * The generator emits `OPERATIONS` literals typed\n * `satisfies Record` against this module, so an\n * incompatible runtime/generated pair fails the consumer's build (the semver skew guard).\n */\n\n/** How one operation parameter is sent: its location plus OpenAPI query-serialization hints. */\nexport type ParamSpec = {\n name: string;\n in: 'path' | 'query' | 'header' | 'cookie';\n style?: 'form' | 'spaceDelimited' | 'pipeDelimited' | 'deepObject';\n explode?: boolean;\n allowReserved?: boolean;\n};\n\n/** One security scheme, denormalized onto the operation (`scheme` names the spec's scheme). */\nexport type SecuritySpec =\n | { scheme: string; kind: 'bearer' | 'basic' }\n | { scheme: string; kind: 'apiKey'; name: string; in: 'header' | 'query' | 'cookie' };\n\n/**\n * How to auto-iterate a paginated operation (drives its `.pages()`/`.items()` members).\n * `nextCursor` and `items` are RFC 6901 JSON pointers into the page (response) value.\n */\nexport type PaginationSpec =\n | {\n style: 'cursor';\n /** The query param the iterator advances with the response's cursor. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the next cursor in the page. */\n nextCursor: string;\n /** Optional pointer to a boolean \"more pages\" flag — `false` stops iteration. */\n hasMore?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n style: 'offset' | 'page';\n /** The numeric query param the iterator advances. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n /** RFC 8288: follow the response's `Link` header `rel=\"next\"`; stop when absent. */\n style: 'link';\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n };\n\n/** The frozen data contract between generated code and the runtime: one operation's wire shape. */\nexport type OperationDescriptor = {\n id: string;\n method: string;\n path: string;\n tags?: readonly string[];\n params?: readonly ParamSpec[];\n /** `multipart: true` marks a typed object body serialized to FormData by the runtime. */\n body?: { contentType: string; multipart?: boolean };\n /** Defaults to `'json'` (content-type negotiation on parse). */\n responseKind?: 'json' | 'text' | 'blob' | 'void' | 'sse';\n sseDataKind?: 'json' | 'text';\n /** OR-alternatives, each an AND-set: the runtime applies the first fully-configured one. */\n security?: readonly (readonly SecuritySpec[])[];\n pagination?: PaginationSpec;\n /**\n * Declared success-response headers for throw-mode `{ envelope: true }`.\n * `name` is the lowercased wire name; `key` is the camelCase envelope property.\n */\n responseHeaders?: readonly ResponseHeaderSpec[];\n};\n\n/** One declared response header the runtime coerces into the envelope `headers` object. */\nexport type ResponseHeaderSpec = {\n name: string;\n key: string;\n type: 'string' | 'number' | 'boolean';\n};\n\n/** A query value: scalars, arrays of scalars, or objects (serialized as deepObject brackets). */\nexport type QueryValue =\n | string\n | number\n | boolean\n | null\n | undefined\n | Array\n | Record;\n\n/** A credential: a literal, or a (possibly async) function resolved per request (refresh flows). */\nexport type TokenProvider = string | (() => string | Promise);\n\n/** Per-instance credentials, keyed by the scheme kinds the runtime can inject. */\nexport type AuthCredentials = {\n bearer?: TokenProvider;\n basic?: { username: string; password: string };\n apiKey?: Record;\n};\n\n/** Backoff shape: 'fixed' = constant delay; 'exponential' = doubling per attempt. */\nexport type RetryStrategy = 'fixed' | 'exponential';\n\n/**\n * The operation's identity, exposed to middleware for targeting (`ctx.operation`).\n * Generated clients instantiate the type parameters with the spec's literal unions\n * (`OperationId`/`OperationPath`/`OperationTag`) so a misspelled operation id in a\n * middleware comparison fails to compile; the string defaults keep every\n * spec-independent consumer (`runtime-contract.ts`, the runtime internals) working\n * with the base shape. `tags` stays mutable (`Tag[]`) so setup-contract types\n * (byte-locked to generated output) remain assignable through middleware callbacks.\n */\nexport type OperationContext<\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n> = { id: Id; path: Path; tags: Tag[] };\n\n/** The mutable request context threaded through the middleware chain. */\nexport type RequestContext = {\n url: string;\n method: string;\n headers: Record;\n body?: unknown;\n operation: Op;\n};\n\n/** The failed attempt handed to a custom `retryOn`: exactly one of `response`/`error` is set. */\nexport type RetryContext = {\n attempt: number;\n request: RequestContext;\n response?: Response;\n error?: unknown;\n};\n\n/** Opt-in retry policy; a per-call override merges field-by-field over the config policy. */\nexport type RetryConfig = {\n retries?: number;\n retryDelay?: number;\n retryStrategy?: RetryStrategy;\n jitter?: boolean;\n retryOn?: (ctx: RetryContext) => boolean | Promise;\n};\n\n/**\n * Structural stand-in for the runtime's ApiError so this module stays import-free\n * (pure types); the real `ApiError` class is assignable to it.\n */\nexport type ApiErrorLike = globalThis.Error & {\n url: string;\n status: number;\n statusText: string;\n body: unknown;\n};\n\n/** One interceptor: any subset of the three hooks. */\nexport type Middleware = {\n onRequest?: (ctx: RequestContext) => void | Promise;\n onResponse?: (\n response: Response,\n ctx: RequestContext\n ) => Response | void | Promise;\n /** Throw mode only: may map/replace the error. */\n // `globalThis.Error` so a spec schema named `Error` cannot shadow it in inline mode.\n onError?: (\n error: ApiErrorLike,\n ctx: RequestContext\n ) => globalThis.Error | Promise;\n};\n\n/** Client configuration: transport, defaults, retry policy, middleware, and credentials. */\nexport type ClientConfig = {\n serverUrl?: string;\n fetch?: typeof fetch;\n headers?:\n | Record\n | (() => Record | Promise>);\n retry?: RetryConfig;\n /** Milliseconds before a request attempt aborts (covers the body read too; each retry\n * attempt gets a fresh budget). Per-call `timeout` overrides it, `0` disables it.\n * SSE streams are long-lived by design and never inherit this value. */\n timeout?: number;\n /** Send an `Idempotency-Key` header on POST/PATCH (one stable key per logical call,\n * reused across retry attempts) — which also makes those retries safe under the\n * default retry policy. `true` generates a UUID per call; a function supplies the key. */\n idempotencyKey?: boolean | (() => string);\n /** Identifies this client to the API via an `X-Redocly-Client` header (the generator\n * bakes a default). Sent only OUTSIDE browsers — a custom header would force a CORS\n * preflight. Override with your own value, or `false` to disable. */\n clientHeader?: string | false;\n middleware?: Middleware[];\n auth?: AuthCredentials;\n /** Fixed at generate time by the generator (`'throw'` when omitted); `configure()` ignores it. */\n errorMode?: 'throw' | 'result';\n onRequest?: Middleware['onRequest'];\n onResponse?: Middleware['onResponse'];\n onError?: Middleware['onError'];\n};\n\n/** Response readers for the per-call `parseAs` override. */\nexport type ParseAs = 'auto' | 'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData' | 'stream';\n\n/** Per-call options: standard `RequestInit` plus a retry override, a timeout override\n * (`0` disables the config default), and a forced reader. */\nexport type RequestOptions = RequestInit & {\n retry?: RetryConfig;\n timeout?: number;\n /** Per-call idempotency key: a literal key, `true` to generate one, `false` to skip. */\n idempotencyKey?: string | boolean | (() => string);\n parseAs?: ParseAs;\n /**\n * Throw mode only: return `{ data, headers, response }` instead of the parsed body;\n * ignored in result mode. The explicit `| undefined` keeps the wrappers' emitted\n * `envelope: undefined` strip legal under `exactOptionalPropertyTypes`.\n */\n envelope?: boolean | undefined;\n};\n\n/** Throw-mode success envelope when `RequestOptions.envelope` is `true`. */\nexport type Envelope> = {\n data: TData;\n headers: THeaders;\n response: Response;\n};\n\n/** Per-call options for an SSE stream; reconnect defaults to true. */\nexport type SseOptions = RequestInit & { reconnect?: boolean; reconnectDelay?: number };\n\n/** A single decoded Server-Sent Event with its payload typed from the spec. */\nexport type ServerSentEvent = { event?: string; data: T; id?: string; retry?: number };\n\n/** Result-mode return shape: exactly one of `data`/`error` is set. */\nexport type Result =\n | { data: TData; error: undefined; response: Response }\n | { data: undefined; error: TError; response: Response };\n\n/**\n * The generated `Ops` type's shape: per-operation args/result, plus `kind: 'sse'` for\n * streams and, for paginated operations, `item` (the page's element type) and — on\n * result-mode clients only — `page` (the RAW page type `.pages()` yields, since\n * iteration unwraps the `Result` envelope the one-shot `result` carries).\n */\nexport type OpsShape = Record<\n string,\n {\n args: object;\n result: unknown;\n kind?: 'sse';\n item?: unknown;\n page?: unknown;\n /** Declared success-response headers for `{ envelope: true }` (camelCase keys). */\n headers?: object;\n /** Result-mode entries ignore the throw-only `envelope` option. */\n mode?: 'result';\n }\n>;\n\n/** The always-present client members (assigned after the operation loop — they win collisions). */\nexport type ClientCore = {\n /** Merge into the config; note `middleware` REPLACES the chain (use `use()` to compose). */\n configure(config: ClientConfig): void;\n /** Append interceptors (composes with baked/publisher middleware). */\n use(...middleware: Middleware[]): void;\n auth: {\n bearer(token: TokenProvider): void;\n basic(username: string, password: string): void;\n apiKey(scheme: string, value: TokenProvider): void;\n };\n};\n\n/**\n * The standard TypeScript optionality probe: `{}` has no required members, so\n * `{} extends A` is true exactly when every member of `A` is optional.\n */\n// oxlint-disable-next-line typescript/no-empty-object-type\ntype NoRequiredKeys = {} extends A ? true : false;\n\n/**\n * The page type `.pages()` yields: the RAW page declared by `page` (the generator\n * writes it only on result-mode paginated entries, whose `result` is the envelope),\n * or the method's own `result` (throw mode — already the raw page).\n */\ntype PageOf = Entry extends { page: unknown }\n ? Entry['page']\n : Entry['result'];\n\n/**\n * The auto-pagination members intersected onto a paginated method — present exactly when\n * the Ops entry declares `item` (the generator writes it only for paginated operations).\n * Args optionality mirrors the method's own; `unknown` otherwise (identity under `&`).\n * Iteration is error-mode-agnostic: `.pages()`/`.items()` yield raw pages/items, and a\n * failed page aborts iteration by throwing `ApiError`, even on result-mode clients; the\n * `onError` middleware hook (throw-mode-only) is not invoked.\n */\ntype Paginated = 'item' extends keyof Entry\n ? NoRequiredKeys extends true\n ? {\n pages(args?: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args?: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : {\n pages(args: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : unknown;\n\n/**\n * The stable identity every client method carries: the SPEC operationId (also set as\n * `fn.name`, but `operationId` is the explicit, minification-proof form) — a robust\n * cache key for consumer wrappers (react-query keys and the like).\n */\nexport type OperationMethodIdentity = { readonly operationId: string };\n\n/** Declared response-header bag for an Ops entry; empty object when none are declared. */\ntype HeadersOf = 'headers' extends keyof Entry\n ? NonNullable\n : Record;\n\n/**\n * Return type of a throw-mode call: the body by default, `Envelope<…>` for a literal\n * `envelope: true`, their union when the flag is a widened `boolean`. Exact\n * `RequestOptions` stays the body — pre-envelope package-mode flat sugar typed every\n * `init` parameter as `RequestOptions`, and widening that would break upgrades without\n * a regenerate. The `keyof` presence gate keeps `{ headers }` / `{ signal }` as the body\n * (`TInit['envelope']` through `TInit & RequestOptions` would otherwise be\n * `boolean | undefined`).\n */\nexport type EnvelopeResult<\n TData,\n THeaders,\n TInit extends RequestOptions | undefined,\n> = TInit extends undefined\n ? TData\n : RequestOptions extends TInit\n ? TInit extends RequestOptions\n ? TData\n : EnvelopeResultForKnownInit\n : EnvelopeResultForKnownInit;\n\ntype EnvelopeResultForKnownInit = 'envelope' extends keyof TInit\n ? [TInit['envelope' & keyof TInit]] extends [true]\n ? Envelope\n : [TInit['envelope' & keyof TInit]] extends [false | undefined]\n ? TData\n : TData | Envelope\n : TData;\n\n/** A one-shot method whose return shape never varies with per-call options. */\ntype BodyMethod =\n NoRequiredKeys extends true\n ? (args?: Entry['args'], init?: RequestOptions) => Promise\n : (args: Entry['args'], init?: RequestOptions) => Promise;\n\n/**\n * One-shot (non-SSE) method: default returns the body; `{ envelope: true }` returns\n * `{ data, headers, response }` with typed declared headers.\n */\ntype ThrowMethod =\n NoRequiredKeys extends true\n ? (\n args?: Entry['args'],\n init?: Init\n ) => Promise, Init>>\n : (\n args: Entry['args'],\n init?: Init\n ) => Promise, Init>>;\n\n/** The typed instance client: one bound method per operation plus the core members. */\nexport type Client = {\n [K in keyof Ops]: Ops[K] extends { kind: 'sse' }\n ? (NoRequiredKeys extends true\n ? (\n args?: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>\n : (\n args: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>) &\n OperationMethodIdentity\n : (Ops[K] extends { mode: 'result' } ? BodyMethod : ThrowMethod) &\n OperationMethodIdentity &\n Paginated;\n} & ClientCore;\n", + "/**\n * The public type surface of the client runtime — `@redocly/client-generator`'s\n * app-facing runtime module. Pure types, no runtime code (excluded from coverage).\n * The generator emits `OPERATIONS` literals typed\n * `satisfies Record` against this module, so an\n * incompatible runtime/generated pair fails the consumer's build (the semver skew guard).\n */\n\n/** How one operation parameter is sent: its location plus OpenAPI query-serialization hints. */\nexport type ParamSpec = {\n name: string;\n in: 'path' | 'query' | 'header' | 'cookie';\n style?: 'form' | 'spaceDelimited' | 'pipeDelimited' | 'deepObject';\n explode?: boolean;\n allowReserved?: boolean;\n};\n\n/** One security scheme, denormalized onto the operation (`scheme` names the spec's scheme). */\nexport type SecuritySpec =\n | { scheme: string; kind: 'bearer' | 'basic' }\n | { scheme: string; kind: 'apiKey'; name: string; in: 'header' | 'query' | 'cookie' };\n\n/**\n * How to auto-iterate a paginated operation (drives its `.pages()`/`.items()` members).\n * `nextCursor` and `items` are RFC 6901 JSON pointers into the page (response) value.\n */\nexport type PaginationSpec =\n | {\n style: 'cursor';\n /** The query param the iterator advances with the response's cursor. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the next cursor in the page. */\n nextCursor: string;\n /** Optional pointer to a boolean \"more pages\" flag — `false` stops iteration. */\n hasMore?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n style: 'offset' | 'page';\n /** The numeric query param the iterator advances. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n /** RFC 8288: follow the response's `Link` header `rel=\"next\"`; stop when absent. */\n style: 'link';\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n };\n\n/** The frozen data contract between generated code and the runtime: one operation's wire shape. */\nexport type OperationDescriptor = {\n id: string;\n method: string;\n path: string;\n tags?: readonly string[];\n params?: readonly ParamSpec[];\n /** `multipart: true` marks a typed object body serialized to FormData by the runtime. */\n body?: { contentType: string; multipart?: boolean };\n /** Defaults to `'json'` (content-type negotiation on parse). */\n responseKind?: 'json' | 'text' | 'blob' | 'void' | 'sse';\n sseDataKind?: 'json' | 'text';\n /** OR-alternatives, each an AND-set: the runtime applies the first fully-configured one. */\n security?: readonly (readonly SecuritySpec[])[];\n pagination?: PaginationSpec;\n /**\n * Declared success-response headers for throw-mode `{ envelope: true }`.\n * `name` is the lowercased wire name; `key` is the camelCase envelope property.\n */\n responseHeaders?: readonly ResponseHeaderSpec[];\n};\n\n/** One declared response header the runtime coerces into the envelope `headers` object. */\nexport type ResponseHeaderSpec = {\n name: string;\n key: string;\n type: 'string' | 'number' | 'boolean';\n};\n\n/** A query value: scalars, arrays of scalars, or objects (serialized as deepObject brackets). */\nexport type QueryValue =\n | string\n | number\n | boolean\n | null\n | undefined\n | Array\n | Record;\n\n/** A credential: a literal, or a (possibly async) function resolved per request (refresh flows). */\nexport type TokenProvider = string | (() => string | Promise);\n\n/** Per-instance credentials, keyed by the scheme kinds the runtime can inject. */\nexport type AuthCredentials = {\n bearer?: TokenProvider;\n basic?: { username: string; password: string };\n apiKey?: Record;\n};\n\n/** Backoff shape: 'fixed' = constant delay; 'exponential' = doubling per attempt. */\nexport type RetryStrategy = 'fixed' | 'exponential';\n\n/**\n * The operation's identity, exposed to middleware for targeting (`ctx.operation`).\n * Generated clients instantiate the type parameters with the spec's literal unions\n * (`OperationId`/`OperationPath`/`OperationTag`) so a misspelled operation id in a\n * middleware comparison fails to compile; the string defaults keep every\n * spec-independent consumer (`runtime-contract.ts`, the runtime internals) working\n * with the base shape. `tags` stays mutable (`Tag[]`) so setup-contract types\n * (byte-locked to generated output) remain assignable through middleware callbacks.\n */\nexport type OperationContext<\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n> = { id: Id; path: Path; tags: Tag[] };\n\n/** The mutable request context threaded through the middleware chain. */\nexport type RequestContext = {\n url: string;\n method: string;\n headers: Record;\n body?: unknown;\n operation: Op;\n};\n\n/** The failed attempt handed to a custom `retryOn`: exactly one of `response`/`error` is set. */\nexport type RetryContext = {\n attempt: number;\n request: RequestContext;\n response?: Response;\n error?: unknown;\n};\n\n/** Opt-in retry policy; a per-call override merges field-by-field over the config policy. */\nexport type RetryConfig = {\n retries?: number;\n retryDelay?: number;\n retryStrategy?: RetryStrategy;\n jitter?: boolean;\n retryOn?: (ctx: RetryContext) => boolean | Promise;\n};\n\n/**\n * Structural stand-in for the runtime's ApiError so this module stays import-free\n * (pure types); the real `ApiError` class is assignable to it.\n */\nexport type ApiErrorLike = globalThis.Error & {\n url: string;\n status: number;\n statusText: string;\n body: unknown;\n};\n\n/** One interceptor: any subset of the three hooks. */\nexport type Middleware = {\n onRequest?: (ctx: RequestContext) => void | Promise;\n onResponse?: (\n response: Response,\n ctx: RequestContext\n ) => Response | void | Promise;\n /** Throw mode only: may map/replace the error. */\n // `globalThis.Error` so a spec schema named `Error` cannot shadow it in inline mode.\n onError?: (\n error: ApiErrorLike,\n ctx: RequestContext\n ) => globalThis.Error | Promise;\n};\n\n/** Client configuration: transport, defaults, retry policy, middleware, and credentials. */\nexport type ClientConfig = {\n serverUrl?: string;\n fetch?: typeof fetch;\n headers?:\n | Record\n | (() => Record | Promise>);\n retry?: RetryConfig;\n /** Milliseconds before a request attempt aborts (covers the body read too; each retry\n * attempt gets a fresh budget). Per-call `timeout` overrides it, `0` disables it.\n * SSE streams are long-lived by design and never inherit this value. */\n timeout?: number;\n /** Send an `Idempotency-Key` header on POST/PATCH (one stable key per logical call,\n * reused across retry attempts) — which also makes those retries safe under the\n * default retry policy. `true` generates a UUID per call; a function supplies the key. */\n idempotencyKey?: boolean | (() => string);\n /** Identifies this client to the API via an `X-Redocly-Client` header (the generator\n * bakes a default). Sent only OUTSIDE browsers — a custom header would force a CORS\n * preflight. Override with your own value, or `false` to disable. */\n clientHeader?: string | false;\n middleware?: Middleware[];\n auth?: AuthCredentials;\n /** Fixed at generate time by the generator (`'throw'` when omitted); `configure()` ignores it. */\n errorMode?: 'throw' | 'result';\n /**\n * How each call spells its inputs: `'grouped'` (the default) namespaces them by layer —\n * `{ path, query, headers, cookies, body }` — and `'flat'` takes one merged object.\n * Fixed at generate time, like `errorMode`, because it shapes the static types.\n */\n argsStyle?: 'grouped' | 'flat';\n onRequest?: Middleware['onRequest'];\n onResponse?: Middleware['onResponse'];\n onError?: Middleware['onError'];\n};\n\n/** Response readers for the per-call `parseAs` override. */\nexport type ParseAs = 'auto' | 'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData' | 'stream';\n\n/** Per-call options: standard `RequestInit` plus a retry override, a timeout override\n * (`0` disables the config default), and a forced reader. */\nexport type RequestOptions = RequestInit & {\n retry?: RetryConfig;\n timeout?: number;\n /** Per-call idempotency key: a literal key, `true` to generate one, `false` to skip. */\n idempotencyKey?: string | boolean | (() => string);\n parseAs?: ParseAs;\n /**\n * Throw mode only: return `{ data, headers, response }` instead of the parsed body;\n * ignored in result mode. The explicit `| undefined` keeps the wrappers' emitted\n * `envelope: undefined` strip legal under `exactOptionalPropertyTypes`.\n */\n envelope?: boolean | undefined;\n};\n\n/** Throw-mode success envelope when `RequestOptions.envelope` is `true`. */\nexport type Envelope> = {\n data: TData;\n headers: THeaders;\n response: Response;\n};\n\n/** Per-call options for an SSE stream; reconnect defaults to true. */\nexport type SseOptions = RequestInit & { reconnect?: boolean; reconnectDelay?: number };\n\n/** A single decoded Server-Sent Event with its payload typed from the spec. */\nexport type ServerSentEvent = { event?: string; data: T; id?: string; retry?: number };\n\n/** Result-mode return shape: exactly one of `data`/`error` is set. */\nexport type Result =\n | { data: TData; error: undefined; response: Response }\n | { data: undefined; error: TError; response: Response };\n\n/**\n * The generated `Ops` type's shape: per-operation args/result, plus `kind: 'sse'` for\n * streams and, for paginated operations, `item` (the page's element type) and — on\n * result-mode clients only — `page` (the RAW page type `.pages()` yields, since\n * iteration unwraps the `Result` envelope the one-shot `result` carries).\n */\nexport type OpsShape = Record<\n string,\n {\n args: object;\n result: unknown;\n kind?: 'sse';\n item?: unknown;\n page?: unknown;\n /** Declared success-response headers for `{ envelope: true }` (camelCase keys). */\n headers?: object;\n /** Result-mode entries ignore the throw-only `envelope` option. */\n mode?: 'result';\n }\n>;\n\n/** The always-present client members (assigned after the operation loop — they win collisions). */\nexport type ClientCore = {\n /** Merge into the config; note `middleware` REPLACES the chain (use `use()` to compose). */\n configure(config: ClientConfig): void;\n /** Append interceptors (composes with baked/publisher middleware). */\n use(...middleware: Middleware[]): void;\n auth: {\n bearer(token: TokenProvider): void;\n basic(username: string, password: string): void;\n apiKey(scheme: string, value: TokenProvider): void;\n };\n};\n\n/**\n * The standard TypeScript optionality probe: `{}` has no required members, so\n * `{} extends A` is true exactly when every member of `A` is optional.\n */\n// oxlint-disable-next-line typescript/no-empty-object-type\ntype NoRequiredKeys = {} extends A ? true : false;\n\n/**\n * The page type `.pages()` yields: the RAW page declared by `page` (the generator\n * writes it only on result-mode paginated entries, whose `result` is the envelope),\n * or the method's own `result` (throw mode — already the raw page).\n */\ntype PageOf = Entry extends { page: unknown }\n ? Entry['page']\n : Entry['result'];\n\n/**\n * The auto-pagination members intersected onto a paginated method — present exactly when\n * the Ops entry declares `item` (the generator writes it only for paginated operations).\n * Args optionality mirrors the method's own; `unknown` otherwise (identity under `&`).\n * Iteration is error-mode-agnostic: `.pages()`/`.items()` yield raw pages/items, and a\n * failed page aborts iteration by throwing `ApiError`, even on result-mode clients; the\n * `onError` middleware hook (throw-mode-only) is not invoked.\n */\ntype Paginated = 'item' extends keyof Entry\n ? NoRequiredKeys extends true\n ? {\n pages(args?: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args?: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : {\n pages(args: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : unknown;\n\n/**\n * The stable identity every client method carries: the SPEC operationId (also set as\n * `fn.name`, but `operationId` is the explicit, minification-proof form) — a robust\n * cache key for consumer wrappers (react-query keys and the like).\n */\nexport type OperationMethodIdentity = { readonly operationId: string };\n\n/** Declared response-header bag for an Ops entry; empty object when none are declared. */\ntype HeadersOf = 'headers' extends keyof Entry\n ? NonNullable\n : Record;\n\n/**\n * Return type of a throw-mode call: the body by default, `Envelope<…>` for a literal\n * `envelope: true`, their union when the flag is a widened `boolean`. Exact\n * `RequestOptions` stays the body — pre-envelope package-mode flat sugar typed every\n * `init` parameter as `RequestOptions`, and widening that would break upgrades without\n * a regenerate. The `keyof` presence gate keeps `{ headers }` / `{ signal }` as the body\n * (`TInit['envelope']` through `TInit & RequestOptions` would otherwise be\n * `boolean | undefined`).\n */\nexport type EnvelopeResult<\n TData,\n THeaders,\n TInit extends RequestOptions | undefined,\n> = TInit extends undefined\n ? TData\n : RequestOptions extends TInit\n ? TInit extends RequestOptions\n ? TData\n : EnvelopeResultForKnownInit\n : EnvelopeResultForKnownInit;\n\ntype EnvelopeResultForKnownInit = 'envelope' extends keyof TInit\n ? [TInit['envelope' & keyof TInit]] extends [true]\n ? Envelope\n : [TInit['envelope' & keyof TInit]] extends [false | undefined]\n ? TData\n : TData | Envelope\n : TData;\n\n/** A one-shot method whose return shape never varies with per-call options. */\ntype BodyMethod =\n NoRequiredKeys extends true\n ? (args?: Entry['args'], init?: RequestOptions) => Promise\n : (args: Entry['args'], init?: RequestOptions) => Promise;\n\n/**\n * One-shot (non-SSE) method: default returns the body; `{ envelope: true }` returns\n * `{ data, headers, response }` with typed declared headers.\n */\ntype ThrowMethod =\n NoRequiredKeys extends true\n ? (\n args?: Entry['args'],\n init?: Init\n ) => Promise, Init>>\n : (\n args: Entry['args'],\n init?: Init\n ) => Promise, Init>>;\n\n/** The typed instance client: one bound method per operation plus the core members. */\nexport type Client = {\n [K in keyof Ops]: Ops[K] extends { kind: 'sse' }\n ? (NoRequiredKeys extends true\n ? (\n args?: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>\n : (\n args: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>) &\n OperationMethodIdentity\n : (Ops[K] extends { mode: 'result' } ? BodyMethod : ThrowMethod) &\n OperationMethodIdentity &\n Paginated;\n} & ClientCore;\n", 'errors.ts': "/** The error thrown (throw mode) for a non-2xx response, carrying the decoded error body. */\nexport class ApiError extends Error {\n public readonly url: string;\n public readonly status: number;\n public readonly statusText: string;\n public readonly body: unknown;\n constructor(url: string, status: number, statusText: string, body: unknown) {\n super(`Request failed with status ${status}`);\n this.name = 'ApiError';\n this.url = url;\n this.status = status;\n this.statusText = statusText;\n this.body = body;\n }\n}\n\n/** The error thrown when a request attempt exceeds the configured `timeout` — carries\n * the context a log line needs (which operation, what budget, which attempt). */\nexport class TimeoutError extends Error {\n public readonly operationId: string;\n public readonly timeout: number;\n public readonly attempt: number;\n constructor(operationId: string, timeout: number, attempt: number) {\n super(`Request \"${operationId}\" timed out after ${timeout} ms (attempt ${attempt})`);\n this.name = 'TimeoutError';\n this.operationId = operationId;\n this.timeout = timeout;\n this.attempt = attempt;\n }\n}\n\n/** The error to throw for an aborted request: the caller's abort reason when it is an Error. */\n// `globalThis.Error` (not bare `Error`) so a spec schema named `Error` cannot shadow it\n// when this module is embedded alongside generated types (inline mode).\nexport function abortError(signal: AbortSignal): globalThis.Error {\n const reason = (signal as { reason?: unknown }).reason;\n if (reason instanceof Error) return reason;\n return new DOMException('The operation was aborted.', 'AbortError');\n}\n", 'url.ts': @@ -21,17 +21,17 @@ export const RUNTIME_SOURCES = { 'sse.ts': "import { ApiError } from './errors.js';\nimport { readError } from './parse.js';\nimport { sleep } from './retry.js';\nimport { send, toHeaderRecord } from './send.js';\nimport type { ClientConfig, OperationContext, ServerSentEvent, SseOptions } from './types.js';\n\n/**\n * A frame delimiter: two consecutive line terminators (each CR, LF, or CRLF, per the SSE\n * spec — so mixed endings like `\\n\\r\\n` are valid boundaries, not just matching pairs).\n */\nconst FRAME_DELIMITER = /(?:\\r\\n|\\r|\\n){2}/;\n\n/**\n * A terminally malformed event stream — unparseable JSON `data` or an unbounded frame.\n * A stable bad payload, not a dropped connection, so the stream never reconnects on it.\n */\nexport class SseParseError extends Error {}\n\n/**\n * Consume a `text/event-stream` operation as typed events (capability module — wired\n * into `createClient`). Auto-reconnects on dropped connections, resuming from the last\n * seen event id via `Last-Event-ID` (backoff: the server's `retry:` value, then\n * `reconnectDelay`, then 1s — exponential with jitter, capped at 30s). A clean stream\n * end flushes a trailing frame and finishes; `break`/abort end the iterator cleanly.\n */\nexport async function* sse(\n config: ClientConfig,\n op: OperationContext,\n prepare: () => Promise<{ url: string; init: SseOptions; body?: unknown }>,\n dataKind: 'json' | 'text' = 'text'\n): AsyncGenerator> {\n let lastEventId: string | undefined;\n let serverRetry: number | undefined;\n let failures = 0;\n while (true) {\n // Re-prepare each attempt so a refresh-style TokenProvider yields a fresh credential\n // on reconnect (the auth is baked into `url` query + `init.headers`). `reconnect`,\n // `reconnectDelay`, and `signal` come from the caller's original options unchanged.\n const { url, init, body: requestBody } = await prepare();\n const { reconnect = true, reconnectDelay, ...rest } = init;\n const signal = rest.signal ?? undefined;\n if (signal?.aborted) return;\n const headers: Record = {\n Accept: 'text/event-stream',\n ...toHeaderRecord(rest.headers),\n };\n const sendHeaders =\n lastEventId === undefined ? headers : { ...headers, 'Last-Event-ID': lastEventId };\n try {\n const { response } = await send(\n config,\n op,\n url,\n // `timeout: 0` opts the stream out of a config-level timeout — an event stream\n // is long-lived by design and must not be severed after N milliseconds.\n { ...rest, method: rest.method ?? 'GET', headers: sendHeaders, timeout: 0 },\n requestBody,\n undefined,\n {}\n );\n if (!response.ok) {\n const errorBody = await readError(response);\n throw new ApiError(url, response.status, response.statusText, errorBody);\n }\n failures = 0;\n const body = response.body;\n if (!body) return;\n const reader = body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n try {\n while (true) {\n const { done, value } = await reader.read();\n buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });\n let match: RegExpExecArray | null;\n while ((match = FRAME_DELIMITER.exec(buffer)) !== null) {\n const raw = buffer.slice(0, match.index);\n buffer = buffer.slice(match.index + match[0].length);\n const event = parseSseFrame(raw, dataKind);\n if (event) {\n if (event.id !== undefined) lastEventId = event.id;\n if (event.retry !== undefined) serverRetry = event.retry;\n yield event as ServerSentEvent;\n }\n }\n if (done) {\n // Stream closed cleanly. Flush a final event that arrived without a trailing\n // delimiter, then finish — a clean end is not a dropped connection, so do not reconnect.\n const event = buffer.length > 0 ? parseSseFrame(buffer, dataKind) : undefined;\n if (event) {\n if (event.id !== undefined) lastEventId = event.id;\n if (event.retry !== undefined) serverRetry = event.retry;\n yield event as ServerSentEvent;\n }\n return;\n }\n // Bound memory: a server that never sends a frame delimiter would otherwise\n // grow `buffer` without limit. 1 MiB is far above any real SSE frame.\n if (buffer.length > 1048576) {\n throw new SseParseError('SSE frame exceeded 1048576 characters without a delimiter');\n }\n }\n } finally {\n await reader.cancel().catch(() => undefined);\n }\n } catch (error) {\n if (signal?.aborted) return;\n // A non-OK HTTP response (4xx/5xx) or an unparseable JSON payload is a definitive\n // error, not a transient drop — surface it instead of reconnecting in a loop (a\n // stable bad payload would otherwise reconnect forever).\n if (error instanceof ApiError || error instanceof SseParseError) throw error;\n // A transport failure (connect/DNS/reset) when opening the request, or a mid-stream\n // read error, is a dropped connection: fall through to backoff/reconnect when enabled.\n if (!reconnect) throw error;\n }\n // Only the swallowed-drop path reaches here: reconnect is on and the signal not aborted.\n failures++;\n const base = serverRetry ?? reconnectDelay ?? 1000;\n const delay = Math.min(base * Math.pow(2, failures - 1), 30_000);\n try {\n await sleep(Math.random() * delay, signal);\n } catch {\n return; // sleep rejects only on abort — end the iterator cleanly\n }\n }\n}\n\n/** Parse one raw SSE frame (its lines) into an event; returns undefined for comment-only frames. */\nexport function parseSseFrame(\n raw: string,\n dataKind: 'json' | 'text'\n): ServerSentEvent | undefined {\n let event: string | undefined;\n const dataLines: string[] = [];\n let id: string | undefined;\n let retry: number | undefined;\n let sawField = false;\n for (const line of raw.split(/\\r\\n|\\n|\\r/)) {\n if (line === '' || line.startsWith(':')) continue;\n const colon = line.indexOf(':');\n const field = colon === -1 ? line : line.slice(0, colon);\n let val = colon === -1 ? '' : line.slice(colon + 1);\n if (val.startsWith(' ')) val = val.slice(1);\n sawField = true;\n if (field === 'event') event = val;\n else if (field === 'data') dataLines.push(val);\n else if (field === 'id') id = val;\n else if (field === 'retry') {\n // ASCII digits only, per the EventSource spec — anything else is ignored\n // (`Number('')` is 0 and would zero the reconnect backoff).\n if (/^\\d+$/.test(val)) retry = Number(val);\n }\n }\n if (!sawField) return undefined;\n const dataText = dataLines.join('\\n');\n let data: unknown = dataText;\n if (dataKind === 'json' && dataText !== '') {\n try {\n data = JSON.parse(dataText);\n } catch (error) {\n throw new SseParseError(\n `Failed to parse SSE event data as JSON: ${error instanceof Error ? error.message : String(error)}`\n );\n }\n }\n return { event, data, id, retry };\n}\n", 'create-client.ts': - "import { ApiError } from './errors.js';\nimport { parse, readError } from './parse.js';\nimport { middlewareChain, send, toHeaderRecord, type SendCapabilities } from './send.js';\nimport type {\n ApiErrorLike,\n Client,\n ClientConfig,\n Middleware,\n OperationContext,\n OperationDescriptor,\n OpsShape,\n PaginationSpec,\n ParseAs,\n QueryValue,\n RequestOptions,\n ResponseHeaderSpec,\n SecuritySpec,\n ServerSentEvent,\n SseOptions,\n TokenProvider,\n} from './types.js';\nimport { buildUrl, substitutePath, type QueryStyle } from './url.js';\n\n/**\n * The optional behaviors `createClientCore` can dispatch to but never statically\n * imports. The package's public `createClient` wires the full set; the future\n * inline-mode assembler wires only the capabilities a spec needs.\n */\nexport type Capabilities = SendCapabilities & {\n resolveAuth?: (\n security: readonly (readonly SecuritySpec[])[],\n config: ClientConfig\n ) => Promise<{ headers: Record; query: Record }>;\n sse?: (\n config: ClientConfig,\n op: OperationContext,\n // Re-preparing per (re)connect (not a frozen url/init) lets a refresh-style\n // TokenProvider issue a fresh credential after a dropped stream reconnects.\n prepare: () => Promise<{ url: string; init: SseOptions }>,\n dataKind: 'json' | 'text'\n ) => AsyncGenerator>;\n paginate?: {\n pages: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n items: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n // The `link`-style iterators need the raw `Link` header + page URL, which the\n // parsed-page call above cannot carry (the shape mirrors paginate's `LinkPageCall`).\n pagesByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n itemsByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n };\n};\n\n/** The grouped args wire shape: path params by name plus the `params`/`body`/`headers`/`cookies` slots. */\nexport type OperationArgs = {\n params?: Record;\n body?: unknown;\n headers?: Record;\n cookies?: Record;\n} & Record;\n\n/** The response reader implied by the descriptor (before any per-call `parseAs` override). */\n/**\n * The `Accept` header matching how the response will be read — a blob/text operation\n * must not ask for `application/json` (a content-negotiating server would 406 or\n * answer with a JSON error body instead of the payload). Caller `init.headers` and\n * `config.headers` still override.\n */\nfunction acceptFor(kind: ParseAs | 'void'): string {\n if (kind === 'text') return 'text/*';\n if (kind === 'blob' || kind === 'arrayBuffer' || kind === 'stream' || kind === 'formData') {\n return '*/*';\n }\n return 'application/json'; // json | auto | void\n}\n\nfunction kindFor(op: OperationDescriptor): ParseAs | 'void' {\n if (op.responseKind === 'void' || op.responseKind === 'blob' || op.responseKind === 'text') {\n return op.responseKind;\n }\n return 'auto';\n}\n\n/** Route the grouped args by the descriptor: path values, query object, body, extra headers, cookies. */\nfunction splitArgs(op: OperationDescriptor, args: OperationArgs) {\n const path: Record = {};\n const pathNames = new Set();\n for (const param of op.params ?? []) {\n if (param.in === 'path') {\n pathNames.add(param.name);\n path[param.name] = args[param.name];\n }\n }\n // An unknown top-level key can only be a bug (usually a flat-style call shape passed\n // to a grouped client: `{ limit: 10 }` instead of `{ params: { limit: 10 } }`).\n // TypeScript catches it, but transpilers that skip type-checking would otherwise\n // ship a request that silently drops the value — fail the call loudly instead.\n for (const key of Object.keys(args)) {\n if (key === 'params' || key === 'body' || key === 'headers' || key === 'cookies') continue;\n if (pathNames.has(key)) continue;\n throw new TypeError(\n `Unknown argument \"${key}\" for operation \"${op.id}\". Query parameters go under params: { … } and the request body under body; valid keys are params, body, headers, cookies` +\n (pathNames.size > 0 ? `, and the path parameters (${[...pathNames].join(', ')}).` : '.')\n );\n }\n return {\n path,\n query: args.params,\n body: args.body,\n headers: args.headers,\n cookies: args.cookies,\n };\n}\n\n/**\n * The query-serialization hints for the descriptor's query params. A spec is built only\n * when the param deviates from the OpenAPI defaults (`form` + `explode: true`, encoded),\n * and always fully resolved — so `explode: false` or `allowReserved` alone (no `style`)\n * are honored, and an omitted `explode` keeps the exploded default.\n */\nfunction queryStyles(op: OperationDescriptor): Record | undefined {\n let styles: Record | undefined;\n for (const param of op.params ?? []) {\n if (param.in !== 'query') continue;\n const deviates =\n (param.style !== undefined && param.style !== 'form') ||\n param.explode === false ||\n param.allowReserved === true;\n if (!deviates) continue;\n styles ??= {};\n styles[param.name] = {\n style: param.style ?? 'form',\n explode: param.explode ?? true,\n allowReserved: param.allowReserved,\n };\n }\n return styles;\n}\n\n/** Stringify caller-supplied extra headers, skipping empty entries. */\nfunction stringHeaders(headers: Record | undefined): Record {\n const out: Record = {};\n for (const [key, value] of Object.entries(headers ?? {})) {\n if (value !== undefined && value !== null) out[key] = String(value);\n }\n return out;\n}\n\n/** Build the request pieces an attempt needs: the final URL and the merged per-call init. */\nasync function prepareRequest(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions | SseOptions,\n caps: Capabilities\n): Promise<{ url: string; init: RequestOptions; body: unknown }> {\n const { path, query, body, headers, cookies } = splitArgs(op, args);\n const authed: { headers: Record; query: Record } =\n op.security?.length && caps.resolveAuth\n ? await caps.resolveAuth(op.security, config)\n : { headers: {}, query: {} };\n // Cookie params join the auth-injected cookies in one `Cookie` header (values\n // percent-encoded, like auth cookies). Server-side only — browsers own the header.\n const cookiePairs = Object.entries(cookies ?? {})\n .filter(([, value]) => value !== undefined && value !== null)\n .map(([cookieName, value]) => `${cookieName}=${encodeURIComponent(String(value))}`);\n if (cookiePairs.length > 0) {\n authed.headers.Cookie = [authed.headers.Cookie, ...cookiePairs].filter(Boolean).join('; ');\n }\n const fullQuery: Record = { ...query, ...authed.query };\n const url = buildUrl(\n config.serverUrl ?? '',\n substitutePath(op.path, path),\n Object.keys(fullQuery).length > 0 ? fullQuery : undefined,\n queryStyles(op)\n );\n const mergedInit: RequestOptions = {\n ...init,\n method: op.method.toUpperCase(),\n // Precedence, lowest → highest (later spreads win): injected auth → explicit\n // header params → caller `init.headers` — the caller always overrides both.\n headers: {\n ...authed.headers,\n ...stringHeaders(headers),\n ...toHeaderRecord(init.headers),\n },\n };\n return { url, init: mergedInit, body };\n}\n\n/** Coerce a single declared response header value; omit when absent or unparsable. */\nfunction coerceResponseHeader(\n raw: string | null,\n type: ResponseHeaderSpec['type']\n): string | number | boolean | undefined {\n if (raw === null) return undefined;\n if (type === 'number') {\n if (raw.trim() === '') return undefined;\n const value = Number(raw);\n return Number.isFinite(value) ? value : undefined;\n }\n if (type === 'boolean') {\n const value = raw.trim().toLowerCase();\n if (value === 'true') return true;\n if (value === 'false') return false;\n return undefined;\n }\n return raw;\n}\n\n/** Build the camelCase declared-header bag for a throw-mode envelope. */\nfunction readEnvelopeHeaders(\n response: Response,\n specs: readonly ResponseHeaderSpec[] | undefined\n): Record {\n const headers: Record = {};\n for (const spec of specs ?? []) {\n const value = coerceResponseHeader(response.headers.get(spec.name), spec.type);\n if (value !== undefined) headers[spec.key] = value;\n }\n return headers;\n}\n\n/** One non-SSE call: send, then branch on the configured error mode. */\nasync function execute(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions,\n caps: Capabilities\n): Promise {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // `parseAs` / `envelope` are client options, not fetch RequestInit fields.\n const { parseAs, envelope, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const { response, context } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (config.errorMode === 'result') {\n if (!response.ok) {\n return { data: undefined, error: await readError(response), response };\n }\n return { data: await parse(response, readKind), error: undefined, response };\n }\n if (!response.ok) {\n let error: globalThis.Error = new ApiError(\n context.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n // Thread the error through each middleware's onError in turn (each may replace it).\n for (const mw of middlewareChain(config)) {\n if (mw.onError) error = await mw.onError(error as ApiErrorLike, context);\n }\n throw error;\n }\n const data = await parse(response, readKind);\n if (envelope === true) {\n return {\n data,\n headers: readEnvelopeHeaders(response, op.responseHeaders),\n response,\n };\n }\n return data;\n}\n\n/** The paginate capability, or a descriptive throw when a paginated op is iterated unwired. */\nfunction paginateCapability(caps: Capabilities, op: OperationDescriptor) {\n if (!caps.paginate) {\n throw new Error(`Pagination capability not wired: cannot iterate operation \"${op.id}\"`);\n }\n return caps.paginate;\n}\n\n/**\n * The per-page call the iterators drive: the method itself in throw mode; in result\n * mode a wrapper that unwraps the `{ data, error, response }` envelope — the page\n * pointers are data-rooted — rethrowing a failed page as `ApiError` (iteration is\n * error-mode-agnostic; the throw-mode-only `onError` middleware hook is not invoked).\n */\nfunction pageCall(\n method: (args?: OperationArgs, init?: RequestOptions) => Promise,\n config: ClientConfig\n) {\n const callWithoutEnvelope = (args?: OperationArgs, init?: RequestOptions) => {\n if (!init || init.envelope === undefined) return method(args, init);\n const { envelope: _envelope, ...pageInit } = init;\n return method(args, pageInit);\n };\n if (config.errorMode !== 'result') return callWithoutEnvelope;\n return async (args?: OperationArgs, init?: RequestOptions) => {\n const envelope = (await callWithoutEnvelope(args, init)) as {\n data: unknown;\n error: unknown;\n response: Response;\n };\n // Failure is `!response.ok` — NOT `data === undefined`: a successful bodyless page\n // (204/void) also parses to undefined data, and a failed page's `error` can be\n // undefined too (unreadable body). The pointers then miss on the undefined data\n // and iteration stops cleanly, which is the correct semantics for an empty page.\n if (!envelope.response.ok) {\n const { response } = envelope;\n throw new ApiError(response.url, response.status, response.statusText, envelope.error);\n }\n return envelope.data;\n };\n}\n\n/**\n * The per-page call the `link`-style iterators drive: like `execute`, but returning the\n * parsed page together with the raw `Link` header and the page's own URL (for resolving\n * a relative `rel=\"next\"` target). Error-mode-agnostic like all iteration: a failed\n * page throws `ApiError` even on result-mode clients.\n */\nfunction linkPageCall(config: ClientConfig, op: OperationDescriptor, caps: Capabilities) {\n return async (args: OperationArgs = {}, init: RequestOptions = {}) => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const { parseAs, envelope: _envelope, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n const { response } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (!response.ok) {\n throw new ApiError(\n prepared.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n }\n return {\n page: await parse(response, readKind),\n linkHeader: response.headers.get('link'),\n // Some `Response` implementations leave `url` empty (mocks, constructed responses).\n url: response.url === '' ? prepared.url : response.url,\n };\n };\n}\n\n/**\n * Build a typed instance client over operation descriptors: one real bound method per\n * operation (attached by a construction-time loop — no Proxy), plus the core members\n * (`configure`/`use`/`auth`), which are assigned AFTER the loop so they win any name\n * collision with an operation. All behavior dispatches through the capability seam.\n */\nexport function createClientCore<\n Ops extends OpsShape,\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n>(\n operations: Record,\n initial: ClientConfig> = {},\n caps: Capabilities = {}\n): Client> {\n // The literal-union narrowing is a compile-time DX contract only; internally the\n // runtime works with the base (string-typed) context. One cast at this boundary —\n // `ClientConfig` is not assignable to `ClientConfig` (middleware ctx\n // params are contravariant).\n const given = initial as ClientConfig;\n // Private mutable config; the middleware array is copied so `use()` never mutates the caller's.\n const config: ClientConfig = { ...given, middleware: [...(given.middleware ?? [])] };\n const client = {} as Record;\n\n for (const [name, op] of Object.entries(operations)) {\n if (op.responseKind === 'sse') {\n const method = (args: OperationArgs = {}, init: SseOptions = {}) => {\n if (!caps.sse) {\n throw new Error(`SSE capability not wired: cannot stream operation \"${op.id}\"`);\n }\n const stream = caps.sse;\n return (async function* () {\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // A thunk the stream re-runs on every (re)connect, so auth (which `prepareRequest`\n // resolves) is refreshed per attempt rather than frozen at the first connect.\n const prepare = async () => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n return { url: prepared.url, init: prepared.init as SseOptions, body: prepared.body };\n };\n yield* stream(config, opCtx, prepare, op.sseDataKind ?? 'text');\n })();\n };\n // Consumers key off the function reference (cache keys, `OPERATIONS[fn.name]`), so\n // each closure carries its operationId instead of an inferred binding name.\n // `operationId` is the explicit, minification-proof form of the same identity\n // (the SPEC operationId — `name` is the emitted key, which a collision may rename).\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n client[name] = method;\n } else {\n const method = (args: OperationArgs = {}, init: RequestOptions = {}) =>\n execute(config, op, args, init, caps);\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n const spec = op.pagination;\n // Paginated ops keep their one-shot call and gain `.pages`/`.items`, dispatching\n // through the capability seam (like SSE: absent capability throws descriptively).\n // Iteration is error-mode-agnostic: the iterators' pointers are data-rooted, so on\n // a result-mode client (`errorMode` is fixed at construction — `configure()`\n // ignores it) each page's envelope is unwrapped before it reaches the capability.\n // A failed page aborts iteration by throwing ApiError, even on result-mode\n // clients; the `onError` middleware hook (throw-mode-only) is not invoked.\n client[name] =\n spec === undefined\n ? method\n : spec.style === 'link'\n ? Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pagesByLink(\n linkPageCall(config, op, caps),\n args,\n init\n ),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).itemsByLink(\n linkPageCall(config, op, caps),\n spec,\n args,\n init\n ),\n })\n : Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pages(pageCall(method, config), spec, args, init),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).items(pageCall(method, config), spec, args, init),\n });\n }\n }\n\n // Core members are assigned AFTER the operation loop — they win over colliding op names.\n client.configure = (next: ClientConfig): void => {\n // `errorMode` is fixed at generate time (it shapes the static types); flipping it at\n // runtime would silently desync return shapes from `Client`, so it is ignored.\n const { errorMode: _fixed, auth, ...rest } = next;\n Object.assign(config, rest);\n // `auth` merges into existing credentials (like the `auth.*` setters) rather than\n // replacing wholesale — so `configure({ auth: { bearer } })` keeps a previously set\n // basic/apiKey. `apiKey` merges per scheme.\n if (auth) {\n config.auth = {\n ...config.auth,\n ...auth,\n ...(auth.apiKey ? { apiKey: { ...config.auth?.apiKey, ...auth.apiKey } } : {}),\n };\n }\n };\n client.use = (...middleware: Middleware[]): void => {\n // Reassign (don't push) so a caller-provided `middleware` array isn't mutated.\n config.middleware = [...(config.middleware ?? []), ...middleware];\n };\n client.auth = {\n bearer(token: TokenProvider): void {\n config.auth = { ...config.auth, bearer: token };\n },\n basic(username: string, password: string): void {\n config.auth = { ...config.auth, basic: { username, password } };\n },\n apiKey(scheme: string, value: TokenProvider): void {\n config.auth = { ...config.auth, apiKey: { ...config.auth?.apiKey, [scheme]: value } };\n },\n };\n\n return client as Client>;\n}\n", + "import { ApiError } from './errors.js';\nimport { parse, readError } from './parse.js';\nimport { middlewareChain, send, toHeaderRecord, type SendCapabilities } from './send.js';\nimport type {\n ApiErrorLike,\n Client,\n ClientConfig,\n Middleware,\n OperationContext,\n OperationDescriptor,\n OpsShape,\n PaginationSpec,\n ParseAs,\n QueryValue,\n RequestOptions,\n ResponseHeaderSpec,\n SecuritySpec,\n ServerSentEvent,\n SseOptions,\n TokenProvider,\n} from './types.js';\nimport { buildUrl, substitutePath, type QueryStyle } from './url.js';\n\n/**\n * The optional behaviors `createClientCore` can dispatch to but never statically\n * imports. The package's public `createClient` wires the full set; the future\n * inline-mode assembler wires only the capabilities a spec needs.\n */\nexport type Capabilities = SendCapabilities & {\n resolveAuth?: (\n security: readonly (readonly SecuritySpec[])[],\n config: ClientConfig\n ) => Promise<{ headers: Record; query: Record }>;\n sse?: (\n config: ClientConfig,\n op: OperationContext,\n // Re-preparing per (re)connect (not a frozen url/init) lets a refresh-style\n // TokenProvider issue a fresh credential after a dropped stream reconnects.\n prepare: () => Promise<{ url: string; init: SseOptions }>,\n dataKind: 'json' | 'text'\n ) => AsyncGenerator>;\n paginate?: {\n pages: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n items: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n // The `link`-style iterators need the raw `Link` header + page URL, which the\n // parsed-page call above cannot carry (the shape mirrors paginate's `LinkPageCall`).\n pagesByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n itemsByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n };\n};\n\n/**\n * One call's inputs, namespaced by transport layer. `argsStyle: 'flat'` clients accept the\n * merged form instead (every parameter and body property at one level) — `namespaceArgs`\n * converts it to this shape before anything downstream reads it.\n */\nexport type OperationArgs = {\n path?: Record;\n query?: Record;\n body?: unknown;\n headers?: Record;\n cookies?: Record;\n} & Record;\n\n/** The five layer keys, and the only top-level keys a namespaced call may carry. */\nconst LAYERS: readonly string[] = ['path', 'query', 'body', 'headers', 'cookies'];\n\n/** Where a declared parameter's `in` value puts it. */\nconst LAYER_OF: Record = {\n path: 'path',\n query: 'query',\n header: 'headers',\n cookie: 'cookies',\n};\n\n/**\n * Merged (`argsStyle: 'flat'`) args → the namespaced shape. A key that names a declared\n * parameter goes to that parameter's layer; anything else is a property of the request\n * body, which is how a flat call spells an object body. `body` stays reserved for the\n * operations a flat call cannot merge (an array, a scalar, or a binary body).\n */\nfunction namespaceArgs(op: OperationDescriptor, args: OperationArgs): OperationArgs {\n const layers: Record> = {};\n let body: unknown;\n let properties: Record | undefined;\n const layerOfParam = new Map((op.params ?? []).map((param) => [param.name, param.in]));\n for (const [key, value] of Object.entries(args)) {\n const layer = LAYER_OF[layerOfParam.get(key) ?? ''];\n if (layer !== undefined) {\n (layers[layer] ??= {})[key] = value;\n } else if (key === 'body' && op.body !== undefined) {\n body = value;\n } else if (op.body !== undefined) {\n (properties ??= {})[key] = value;\n } else {\n throw new TypeError(\n `Unknown argument \"${key}\" for operation \"${op.id}\": it names no declared parameter, and the operation takes no request body.`\n );\n }\n }\n const namespaced: OperationArgs = {};\n if (layers.path) namespaced.path = layers.path;\n // The flat surface types every query value, so the collected bag is one by construction.\n if (layers.query) namespaced.query = layers.query as Record;\n if (layers.headers) namespaced.headers = layers.headers;\n if (layers.cookies) namespaced.cookies = layers.cookies;\n if (properties !== undefined) namespaced.body = properties;\n else if (body !== undefined) namespaced.body = body;\n return namespaced;\n}\n\n/** The response reader implied by the descriptor (before any per-call `parseAs` override). */\n/**\n * The `Accept` header matching how the response will be read — a blob/text operation\n * must not ask for `application/json` (a content-negotiating server would 406 or\n * answer with a JSON error body instead of the payload). Caller `init.headers` and\n * `config.headers` still override.\n */\nfunction acceptFor(kind: ParseAs | 'void'): string {\n if (kind === 'text') return 'text/*';\n if (kind === 'blob' || kind === 'arrayBuffer' || kind === 'stream' || kind === 'formData') {\n return '*/*';\n }\n return 'application/json'; // json | auto | void\n}\n\nfunction kindFor(op: OperationDescriptor): ParseAs | 'void' {\n if (op.responseKind === 'void' || op.responseKind === 'blob' || op.responseKind === 'text') {\n return op.responseKind;\n }\n return 'auto';\n}\n\n/** The call's inputs in namespaced form, converting first on a flat-style client. */\nfunction inputOf(\n op: OperationDescriptor,\n args: OperationArgs,\n config: ClientConfig\n): OperationArgs {\n return config.argsStyle === 'flat' ? namespaceArgs(op, args) : args;\n}\n\n/** Route the namespaced args to the request pieces. */\nfunction splitArgs(op: OperationDescriptor, args: OperationArgs) {\n // An unknown layer key can only be a bug (usually flat-style args on a namespaced\n // client). TypeScript catches it, but a transpiler that skips type-checking would\n // otherwise ship a request that silently drops the value — fail the call loudly.\n for (const key of Object.keys(args)) {\n if (!LAYERS.includes(key)) {\n throw new TypeError(\n `Unknown argument \"${key}\" for operation \"${op.id}\". Inputs are grouped by layer: ${LAYERS.join(', ')}.`\n );\n }\n }\n return {\n path: args.path ?? {},\n query: args.query,\n body: args.body,\n headers: args.headers,\n cookies: args.cookies,\n };\n}\n\n/**\n * The query-serialization hints for the descriptor's query params. A spec is built only\n * when the param deviates from the OpenAPI defaults (`form` + `explode: true`, encoded),\n * and always fully resolved — so `explode: false` or `allowReserved` alone (no `style`)\n * are honored, and an omitted `explode` keeps the exploded default.\n */\nfunction queryStyles(op: OperationDescriptor): Record | undefined {\n let styles: Record | undefined;\n for (const param of op.params ?? []) {\n if (param.in !== 'query') continue;\n const deviates =\n (param.style !== undefined && param.style !== 'form') ||\n param.explode === false ||\n param.allowReserved === true;\n if (!deviates) continue;\n styles ??= {};\n styles[param.name] = {\n style: param.style ?? 'form',\n explode: param.explode ?? true,\n allowReserved: param.allowReserved,\n };\n }\n return styles;\n}\n\n/** Stringify caller-supplied extra headers, skipping empty entries. */\nfunction stringHeaders(headers: Record | undefined): Record {\n const out: Record = {};\n for (const [key, value] of Object.entries(headers ?? {})) {\n if (value !== undefined && value !== null) out[key] = String(value);\n }\n return out;\n}\n\n/** Build the request pieces an attempt needs: the final URL and the merged per-call init. */\nasync function prepareRequest(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions | SseOptions,\n caps: Capabilities\n): Promise<{ url: string; init: RequestOptions; body: unknown }> {\n const { path, query, body, headers, cookies } = splitArgs(op, args);\n const authed: { headers: Record; query: Record } =\n op.security?.length && caps.resolveAuth\n ? await caps.resolveAuth(op.security, config)\n : { headers: {}, query: {} };\n // Cookie params join the auth-injected cookies in one `Cookie` header (values\n // percent-encoded, like auth cookies). Server-side only — browsers own the header.\n const cookiePairs = Object.entries(cookies ?? {})\n .filter(([, value]) => value !== undefined && value !== null)\n .map(([cookieName, value]) => `${cookieName}=${encodeURIComponent(String(value))}`);\n if (cookiePairs.length > 0) {\n authed.headers.Cookie = [authed.headers.Cookie, ...cookiePairs].filter(Boolean).join('; ');\n }\n const fullQuery: Record = { ...query, ...authed.query };\n const url = buildUrl(\n config.serverUrl ?? '',\n substitutePath(op.path, path),\n Object.keys(fullQuery).length > 0 ? fullQuery : undefined,\n queryStyles(op)\n );\n const mergedInit: RequestOptions = {\n ...init,\n method: op.method.toUpperCase(),\n // Precedence, lowest → highest (later spreads win): injected auth → explicit\n // header params → caller `init.headers` — the caller always overrides both.\n headers: {\n ...authed.headers,\n ...stringHeaders(headers),\n ...toHeaderRecord(init.headers),\n },\n };\n return { url, init: mergedInit, body };\n}\n\n/** Coerce a single declared response header value; omit when absent or unparsable. */\nfunction coerceResponseHeader(\n raw: string | null,\n type: ResponseHeaderSpec['type']\n): string | number | boolean | undefined {\n if (raw === null) return undefined;\n if (type === 'number') {\n if (raw.trim() === '') return undefined;\n const value = Number(raw);\n return Number.isFinite(value) ? value : undefined;\n }\n if (type === 'boolean') {\n const value = raw.trim().toLowerCase();\n if (value === 'true') return true;\n if (value === 'false') return false;\n return undefined;\n }\n return raw;\n}\n\n/** Build the camelCase declared-header bag for a throw-mode envelope. */\nfunction readEnvelopeHeaders(\n response: Response,\n specs: readonly ResponseHeaderSpec[] | undefined\n): Record {\n const headers: Record = {};\n for (const spec of specs ?? []) {\n const value = coerceResponseHeader(response.headers.get(spec.name), spec.type);\n if (value !== undefined) headers[spec.key] = value;\n }\n return headers;\n}\n\n/** One non-SSE call: send, then branch on the configured error mode. */\nasync function execute(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions,\n caps: Capabilities\n): Promise {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // `parseAs` / `envelope` are client options, not fetch RequestInit fields.\n const { parseAs, envelope, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const { response, context } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (config.errorMode === 'result') {\n if (!response.ok) {\n return { data: undefined, error: await readError(response), response };\n }\n return { data: await parse(response, readKind), error: undefined, response };\n }\n if (!response.ok) {\n let error: globalThis.Error = new ApiError(\n context.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n // Thread the error through each middleware's onError in turn (each may replace it).\n for (const mw of middlewareChain(config)) {\n if (mw.onError) error = await mw.onError(error as ApiErrorLike, context);\n }\n throw error;\n }\n const data = await parse(response, readKind);\n if (envelope === true) {\n return {\n data,\n headers: readEnvelopeHeaders(response, op.responseHeaders),\n response,\n };\n }\n return data;\n}\n\n/** The paginate capability, or a descriptive throw when a paginated op is iterated unwired. */\nfunction paginateCapability(caps: Capabilities, op: OperationDescriptor) {\n if (!caps.paginate) {\n throw new Error(`Pagination capability not wired: cannot iterate operation \"${op.id}\"`);\n }\n return caps.paginate;\n}\n\n/**\n * The per-page call the iterators drive: the method itself in throw mode; in result\n * mode a wrapper that unwraps the `{ data, error, response }` envelope — the page\n * pointers are data-rooted — rethrowing a failed page as `ApiError` (iteration is\n * error-mode-agnostic; the throw-mode-only `onError` middleware hook is not invoked).\n */\nfunction pageCall(\n method: (args?: OperationArgs, init?: RequestOptions) => Promise,\n config: ClientConfig\n) {\n const callWithoutEnvelope = (args?: OperationArgs, init?: RequestOptions) => {\n if (!init || init.envelope === undefined) return method(args, init);\n const { envelope: _envelope, ...pageInit } = init;\n return method(args, pageInit);\n };\n if (config.errorMode !== 'result') return callWithoutEnvelope;\n return async (args?: OperationArgs, init?: RequestOptions) => {\n const envelope = (await callWithoutEnvelope(args, init)) as {\n data: unknown;\n error: unknown;\n response: Response;\n };\n // Failure is `!response.ok` — NOT `data === undefined`: a successful bodyless page\n // (204/void) also parses to undefined data, and a failed page's `error` can be\n // undefined too (unreadable body). The pointers then miss on the undefined data\n // and iteration stops cleanly, which is the correct semantics for an empty page.\n if (!envelope.response.ok) {\n const { response } = envelope;\n throw new ApiError(response.url, response.status, response.statusText, envelope.error);\n }\n return envelope.data;\n };\n}\n\n/**\n * The per-page call the `link`-style iterators drive: like `execute`, but returning the\n * parsed page together with the raw `Link` header and the page's own URL (for resolving\n * a relative `rel=\"next\"` target). Error-mode-agnostic like all iteration: a failed\n * page throws `ApiError` even on result-mode clients.\n */\nfunction linkPageCall(config: ClientConfig, op: OperationDescriptor, caps: Capabilities) {\n return async (args: OperationArgs = {}, init: RequestOptions = {}) => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const { parseAs, envelope: _envelope, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n const { response } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (!response.ok) {\n throw new ApiError(\n prepared.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n }\n return {\n page: await parse(response, readKind),\n linkHeader: response.headers.get('link'),\n // Some `Response` implementations leave `url` empty (mocks, constructed responses).\n url: response.url === '' ? prepared.url : response.url,\n };\n };\n}\n\n/**\n * Build a typed instance client over operation descriptors: one real bound method per\n * operation (attached by a construction-time loop — no Proxy), plus the core members\n * (`configure`/`use`/`auth`), which are assigned AFTER the loop so they win any name\n * collision with an operation. All behavior dispatches through the capability seam.\n */\nexport function createClientCore<\n Ops extends OpsShape,\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n>(\n operations: Record,\n initial: ClientConfig> = {},\n caps: Capabilities = {}\n): Client> {\n // The literal-union narrowing is a compile-time DX contract only; internally the\n // runtime works with the base (string-typed) context. One cast at this boundary —\n // `ClientConfig` is not assignable to `ClientConfig` (middleware ctx\n // params are contravariant).\n const given = initial as ClientConfig;\n // Private mutable config; the middleware array is copied so `use()` never mutates the caller's.\n const config: ClientConfig = { ...given, middleware: [...(given.middleware ?? [])] };\n const client = {} as Record;\n\n for (const [name, op] of Object.entries(operations)) {\n if (op.responseKind === 'sse') {\n const method = (given: OperationArgs = {}, init: SseOptions = {}) => {\n const args = inputOf(op, given, config);\n if (!caps.sse) {\n throw new Error(`SSE capability not wired: cannot stream operation \"${op.id}\"`);\n }\n const stream = caps.sse;\n return (async function* () {\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // A thunk the stream re-runs on every (re)connect, so auth (which `prepareRequest`\n // resolves) is refreshed per attempt rather than frozen at the first connect.\n const prepare = async () => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n return { url: prepared.url, init: prepared.init as SseOptions, body: prepared.body };\n };\n yield* stream(config, opCtx, prepare, op.sseDataKind ?? 'text');\n })();\n };\n // Consumers key off the function reference (cache keys, `OPERATIONS[fn.name]`), so\n // each closure carries its operationId instead of an inferred binding name.\n // `operationId` is the explicit, minification-proof form of the same identity\n // (the SPEC operationId — `name` is the emitted key, which a collision may rename).\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n client[name] = method;\n } else {\n // `raw` takes namespaced args; `method` is the public entry that accepts whichever\n // style the client was generated with. The iterators namespace once and then drive\n // `raw`, so a flat call is never converted twice.\n const raw = (args: OperationArgs = {}, init: RequestOptions = {}) =>\n execute(config, op, args, init, caps);\n const method = (args: OperationArgs = {}, init: RequestOptions = {}) =>\n raw(inputOf(op, args, config), init);\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n const spec = op.pagination;\n // Paginated ops keep their one-shot call and gain `.pages`/`.items`, dispatching\n // through the capability seam (like SSE: absent capability throws descriptively).\n // Iteration is error-mode-agnostic: the iterators' pointers are data-rooted, so on\n // a result-mode client (`errorMode` is fixed at construction — `configure()`\n // ignores it) each page's envelope is unwrapped before it reaches the capability.\n // A failed page aborts iteration by throwing ApiError, even on result-mode\n // clients; the `onError` middleware hook (throw-mode-only) is not invoked.\n client[name] =\n spec === undefined\n ? method\n : spec.style === 'link'\n ? Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pagesByLink(\n linkPageCall(config, op, caps),\n inputOf(op, args ?? {}, config),\n init\n ),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).itemsByLink(\n linkPageCall(config, op, caps),\n spec,\n inputOf(op, args ?? {}, config),\n init\n ),\n })\n : Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pages(\n pageCall(raw, config),\n spec,\n inputOf(op, args ?? {}, config),\n init\n ),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).items(\n pageCall(raw, config),\n spec,\n inputOf(op, args ?? {}, config),\n init\n ),\n });\n }\n }\n\n // Core members are assigned AFTER the operation loop — they win over colliding op names.\n client.configure = (next: ClientConfig): void => {\n // `errorMode` and `argsStyle` are fixed at generate time (they shape the static types);\n // flipping either at runtime would silently desync the calls from `Client`, so both\n // are ignored here.\n const { errorMode: _fixedMode, argsStyle: _fixedStyle, auth, ...rest } = next;\n Object.assign(config, rest);\n // `auth` merges into existing credentials (like the `auth.*` setters) rather than\n // replacing wholesale — so `configure({ auth: { bearer } })` keeps a previously set\n // basic/apiKey. `apiKey` merges per scheme.\n if (auth) {\n config.auth = {\n ...config.auth,\n ...auth,\n ...(auth.apiKey ? { apiKey: { ...config.auth?.apiKey, ...auth.apiKey } } : {}),\n };\n }\n };\n client.use = (...middleware: Middleware[]): void => {\n // Reassign (don't push) so a caller-provided `middleware` array isn't mutated.\n config.middleware = [...(config.middleware ?? []), ...middleware];\n };\n client.auth = {\n bearer(token: TokenProvider): void {\n config.auth = { ...config.auth, bearer: token };\n },\n basic(username: string, password: string): void {\n config.auth = { ...config.auth, basic: { username, password } };\n },\n apiKey(scheme: string, value: TokenProvider): void {\n config.auth = { ...config.auth, apiKey: { ...config.auth?.apiKey, [scheme]: value } };\n },\n };\n\n return client as Client>;\n}\n", 'paginate.ts': - "import type { OperationArgs } from './create-client.js';\nimport type { PaginationSpec, QueryValue, RequestOptions } from './types.js';\n\n/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `params` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nexport function resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `params[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nexport async function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.params?.[spec.param];\n while (true) {\n const params = { ...args.params };\n if (cursor !== undefined) params[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, params }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `params[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.params?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call(\n { ...args, params: { ...args.params, [spec.param]: position } },\n init\n );\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nexport async function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\nexport type LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nexport function linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nexport async function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let params = args.params;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, params }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n params = { ...args.params, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nexport async function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n", + "import type { OperationArgs } from './create-client.js';\nimport type { PaginationSpec, QueryValue, RequestOptions } from './types.js';\n\n/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `query` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nexport function resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `query[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nexport async function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.query?.[spec.param];\n while (true) {\n const query = { ...args.query };\n if (cursor !== undefined) query[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, query }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `query[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.query?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call({ ...args, query: { ...args.query, [spec.param]: position } }, init);\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nexport async function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\nexport type LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nexport function linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nexport async function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let query = args.query;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, query }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n query = { ...args.query, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nexport async function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n", 'cli.ts': - "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n binName: string;\n /** Credential variable prefix. Defaults to `binName`, constant-cased — set it when the\n * displayed name and the credential family must differ (a composed multi-API binary). */\n envPrefix?: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\nexport type CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\nexport type CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\nexport type CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\nexport type CommandSource = {\n namespace?: string;\n commands: Array;\n /** Absent = inherit the first wired source's — a root `login` shares the binary's identity. */\n wiring?: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nexport function groupSlug(group: string): string {\n return group\n .toLowerCase()\n .split(/[^a-z0-9]+/)\n .filter(Boolean)\n .join('-');\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n // An untagged operation is only ever addressed by its bare name, so when that name is also\n // a group slug the name wins — reading it as the group would leave the command unreachable.\n // A tagged operation in the same position keeps yielding to group help: it is still\n // reachable as ` `.\n const untagged = commands.some((c) => c.group === undefined && c.name === argv[0]);\n let command: CliCommand | undefined;\n let rest: string[];\n if (!untagged && slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nexport function envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nexport async function runCli(sources: CommandSource[], argv: string[]): Promise;\nexport async function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // A source without wiring inherits the first wired one, so the documented root-source\n // shape `{ commands: [login] }` works: the login shares the composed binary's identity.\n const inherited = sources.find((source) => source.wiring !== undefined)?.wiring;\n const wiringOf = (source: CommandSource): CliWiring => source.wiring ?? (inherited as CliWiring);\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = wiringOf(sources[0]);\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.binName)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, wiringOf(source), argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, wiringOf(root as CommandSource), argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], binName: string): string[] {\n const lines = [`Usage: ${binName} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${binName} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n for (const line of renderHelp(\n commands,\n wiring.binName,\n wiring.schemes ?? [],\n prefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n // Redaction matches these against header VALUES — so basic auth must contribute the\n // form the wire actually carries (`Basic ${base64(user:pass)}`), not just the raw\n // password, which never appears in the encoded header.\n const basic = auth.basic as { username: string; password: string } | undefined;\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(basic ? [basic.password, btoa(`${basic.username}:${basic.password}`)] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n // An SSE method returns a lazy stream — drain the stubbed response so its fetch\n // actually runs and captures the request.\n if (command.sse) for await (const _event of result as AsyncIterable);\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", + "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /**\n * Present when the operation takes a JSON request body. `merged` marks a body whose own\n * properties a flat-style call spells at the top level (the generator decides this from\n * the schema, so the CLI and the client can never disagree).\n */\n body?: { required: boolean; merged?: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n binName: string;\n /** Credential variable prefix. Defaults to `binName`, constant-cased — set it when the\n * displayed name and the credential family must differ (a composed multi-API binary). */\n envPrefix?: string;\n /** The generated instance client. */\n client: Record;\n /** How that client takes its inputs. Defaults to `'grouped'`, the generated default. */\n argsStyle?: 'grouped' | 'flat';\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\nexport type CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\nexport type CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\nexport type CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\nexport type CommandSource = {\n namespace?: string;\n commands: Array;\n /** Absent = inherit the first wired source's — a root `login` shares the binary's identity. */\n wiring?: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nexport function groupSlug(group: string): string {\n return group\n .toLowerCase()\n .split(/[^a-z0-9]+/)\n .filter(Boolean)\n .join('-');\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/**\n * The parsed argv as one call input, in the style the wired client takes: grouped by layer\n * (the default) or merged into one object.\n */\nfunction callInputs(\n command: CliCommand,\n positionals: Record,\n params: Record,\n body: unknown,\n argsStyle: CliWiring['argsStyle']\n): Record | undefined {\n const inputs: Record = {};\n if (argsStyle === 'flat') {\n Object.assign(inputs, positionals, params);\n if (body !== undefined) {\n if (command.body?.merged === true) Object.assign(inputs, body as Record);\n else inputs.body = body;\n }\n } else {\n if (Object.keys(positionals).length > 0) inputs.path = positionals;\n if (Object.keys(params).length > 0) inputs.query = params;\n if (body !== undefined) inputs.body = body;\n }\n return Object.keys(inputs).length > 0 ? inputs : undefined;\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n // An untagged operation is only ever addressed by its bare name, so when that name is also\n // a group slug the name wins — reading it as the group would leave the command unreachable.\n // A tagged operation in the same position keeps yielding to group help: it is still\n // reachable as ` `.\n const untagged = commands.some((c) => c.group === undefined && c.name === argv[0]);\n let command: CliCommand | undefined;\n let rest: string[];\n if (!untagged && slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nexport function envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nexport async function runCli(sources: CommandSource[], argv: string[]): Promise;\nexport async function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // A source without wiring inherits the first wired one, so the documented root-source\n // shape `{ commands: [login] }` works: the login shares the composed binary's identity.\n const inherited = sources.find((source) => source.wiring !== undefined)?.wiring;\n const wiringOf = (source: CommandSource): CliWiring => source.wiring ?? (inherited as CliWiring);\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = wiringOf(sources[0]);\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.binName)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, wiringOf(source), argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, wiringOf(root as CommandSource), argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], binName: string): string[] {\n const lines = [`Usage: ${binName} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${binName} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n for (const line of renderHelp(\n commands,\n wiring.binName,\n wiring.schemes ?? [],\n prefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n // Redaction matches these against header VALUES — so basic auth must contribute the\n // form the wire actually carries (`Basic ${base64(user:pass)}`), not just the raw\n // password, which never appears in the encoded header.\n const basic = auth.basic as { username: string; password: string } | undefined;\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(basic ? [basic.password, btoa(`${basic.username}:${basic.password}`)] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const argument = callInputs(command, positionals, params, body, wiring.argsStyle);\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n // An SSE method returns a lazy stream — drain the stubbed response so its fetch\n // actually runs and captures the request.\n if (command.sse) for await (const _event of result as AsyncIterable);\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", } as const; /** Inline-embed variants: imports dropped, `export` stripped outside the kept surface. */ export const RUNTIME_SOURCES_STRIPPED = { 'types.ts': - "/**\n * The public type surface of the client runtime — `@redocly/client-generator`'s\n * app-facing runtime module. Pure types, no runtime code (excluded from coverage).\n * The generator emits `OPERATIONS` literals typed\n * `satisfies Record` against this module, so an\n * incompatible runtime/generated pair fails the consumer's build (the semver skew guard).\n */\n\n/** How one operation parameter is sent: its location plus OpenAPI query-serialization hints. */\nexport type ParamSpec = {\n name: string;\n in: 'path' | 'query' | 'header' | 'cookie';\n style?: 'form' | 'spaceDelimited' | 'pipeDelimited' | 'deepObject';\n explode?: boolean;\n allowReserved?: boolean;\n};\n\n/** One security scheme, denormalized onto the operation (`scheme` names the spec's scheme). */\nexport type SecuritySpec =\n | { scheme: string; kind: 'bearer' | 'basic' }\n | { scheme: string; kind: 'apiKey'; name: string; in: 'header' | 'query' | 'cookie' };\n\n/**\n * How to auto-iterate a paginated operation (drives its `.pages()`/`.items()` members).\n * `nextCursor` and `items` are RFC 6901 JSON pointers into the page (response) value.\n */\nexport type PaginationSpec =\n | {\n style: 'cursor';\n /** The query param the iterator advances with the response's cursor. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the next cursor in the page. */\n nextCursor: string;\n /** Optional pointer to a boolean \"more pages\" flag — `false` stops iteration. */\n hasMore?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n style: 'offset' | 'page';\n /** The numeric query param the iterator advances. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n /** RFC 8288: follow the response's `Link` header `rel=\"next\"`; stop when absent. */\n style: 'link';\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n };\n\n/** The frozen data contract between generated code and the runtime: one operation's wire shape. */\nexport type OperationDescriptor = {\n id: string;\n method: string;\n path: string;\n tags?: readonly string[];\n params?: readonly ParamSpec[];\n /** `multipart: true` marks a typed object body serialized to FormData by the runtime. */\n body?: { contentType: string; multipart?: boolean };\n /** Defaults to `'json'` (content-type negotiation on parse). */\n responseKind?: 'json' | 'text' | 'blob' | 'void' | 'sse';\n sseDataKind?: 'json' | 'text';\n /** OR-alternatives, each an AND-set: the runtime applies the first fully-configured one. */\n security?: readonly (readonly SecuritySpec[])[];\n pagination?: PaginationSpec;\n /**\n * Declared success-response headers for throw-mode `{ envelope: true }`.\n * `name` is the lowercased wire name; `key` is the camelCase envelope property.\n */\n responseHeaders?: readonly ResponseHeaderSpec[];\n};\n\n/** One declared response header the runtime coerces into the envelope `headers` object. */\nexport type ResponseHeaderSpec = {\n name: string;\n key: string;\n type: 'string' | 'number' | 'boolean';\n};\n\n/** A query value: scalars, arrays of scalars, or objects (serialized as deepObject brackets). */\nexport type QueryValue =\n | string\n | number\n | boolean\n | null\n | undefined\n | Array\n | Record;\n\n/** A credential: a literal, or a (possibly async) function resolved per request (refresh flows). */\nexport type TokenProvider = string | (() => string | Promise);\n\n/** Per-instance credentials, keyed by the scheme kinds the runtime can inject. */\nexport type AuthCredentials = {\n bearer?: TokenProvider;\n basic?: { username: string; password: string };\n apiKey?: Record;\n};\n\n/** Backoff shape: 'fixed' = constant delay; 'exponential' = doubling per attempt. */\nexport type RetryStrategy = 'fixed' | 'exponential';\n\n/**\n * The operation's identity, exposed to middleware for targeting (`ctx.operation`).\n * Generated clients instantiate the type parameters with the spec's literal unions\n * (`OperationId`/`OperationPath`/`OperationTag`) so a misspelled operation id in a\n * middleware comparison fails to compile; the string defaults keep every\n * spec-independent consumer (`runtime-contract.ts`, the runtime internals) working\n * with the base shape. `tags` stays mutable (`Tag[]`) so setup-contract types\n * (byte-locked to generated output) remain assignable through middleware callbacks.\n */\nexport type OperationContext<\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n> = { id: Id; path: Path; tags: Tag[] };\n\n/** The mutable request context threaded through the middleware chain. */\nexport type RequestContext = {\n url: string;\n method: string;\n headers: Record;\n body?: unknown;\n operation: Op;\n};\n\n/** The failed attempt handed to a custom `retryOn`: exactly one of `response`/`error` is set. */\nexport type RetryContext = {\n attempt: number;\n request: RequestContext;\n response?: Response;\n error?: unknown;\n};\n\n/** Opt-in retry policy; a per-call override merges field-by-field over the config policy. */\nexport type RetryConfig = {\n retries?: number;\n retryDelay?: number;\n retryStrategy?: RetryStrategy;\n jitter?: boolean;\n retryOn?: (ctx: RetryContext) => boolean | Promise;\n};\n\n/**\n * Structural stand-in for the runtime's ApiError so this module stays import-free\n * (pure types); the real `ApiError` class is assignable to it.\n */\nexport type ApiErrorLike = globalThis.Error & {\n url: string;\n status: number;\n statusText: string;\n body: unknown;\n};\n\n/** One interceptor: any subset of the three hooks. */\nexport type Middleware = {\n onRequest?: (ctx: RequestContext) => void | Promise;\n onResponse?: (\n response: Response,\n ctx: RequestContext\n ) => Response | void | Promise;\n /** Throw mode only: may map/replace the error. */\n // `globalThis.Error` so a spec schema named `Error` cannot shadow it in inline mode.\n onError?: (\n error: ApiErrorLike,\n ctx: RequestContext\n ) => globalThis.Error | Promise;\n};\n\n/** Client configuration: transport, defaults, retry policy, middleware, and credentials. */\nexport type ClientConfig = {\n serverUrl?: string;\n fetch?: typeof fetch;\n headers?:\n | Record\n | (() => Record | Promise>);\n retry?: RetryConfig;\n /** Milliseconds before a request attempt aborts (covers the body read too; each retry\n * attempt gets a fresh budget). Per-call `timeout` overrides it, `0` disables it.\n * SSE streams are long-lived by design and never inherit this value. */\n timeout?: number;\n /** Send an `Idempotency-Key` header on POST/PATCH (one stable key per logical call,\n * reused across retry attempts) — which also makes those retries safe under the\n * default retry policy. `true` generates a UUID per call; a function supplies the key. */\n idempotencyKey?: boolean | (() => string);\n /** Identifies this client to the API via an `X-Redocly-Client` header (the generator\n * bakes a default). Sent only OUTSIDE browsers — a custom header would force a CORS\n * preflight. Override with your own value, or `false` to disable. */\n clientHeader?: string | false;\n middleware?: Middleware[];\n auth?: AuthCredentials;\n /** Fixed at generate time by the generator (`'throw'` when omitted); `configure()` ignores it. */\n errorMode?: 'throw' | 'result';\n onRequest?: Middleware['onRequest'];\n onResponse?: Middleware['onResponse'];\n onError?: Middleware['onError'];\n};\n\n/** Response readers for the per-call `parseAs` override. */\nexport type ParseAs = 'auto' | 'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData' | 'stream';\n\n/** Per-call options: standard `RequestInit` plus a retry override, a timeout override\n * (`0` disables the config default), and a forced reader. */\nexport type RequestOptions = RequestInit & {\n retry?: RetryConfig;\n timeout?: number;\n /** Per-call idempotency key: a literal key, `true` to generate one, `false` to skip. */\n idempotencyKey?: string | boolean | (() => string);\n parseAs?: ParseAs;\n /**\n * Throw mode only: return `{ data, headers, response }` instead of the parsed body;\n * ignored in result mode. The explicit `| undefined` keeps the wrappers' emitted\n * `envelope: undefined` strip legal under `exactOptionalPropertyTypes`.\n */\n envelope?: boolean | undefined;\n};\n\n/** Throw-mode success envelope when `RequestOptions.envelope` is `true`. */\nexport type Envelope> = {\n data: TData;\n headers: THeaders;\n response: Response;\n};\n\n/** Per-call options for an SSE stream; reconnect defaults to true. */\nexport type SseOptions = RequestInit & { reconnect?: boolean; reconnectDelay?: number };\n\n/** A single decoded Server-Sent Event with its payload typed from the spec. */\nexport type ServerSentEvent = { event?: string; data: T; id?: string; retry?: number };\n\n/** Result-mode return shape: exactly one of `data`/`error` is set. */\nexport type Result =\n | { data: TData; error: undefined; response: Response }\n | { data: undefined; error: TError; response: Response };\n\n/**\n * The generated `Ops` type's shape: per-operation args/result, plus `kind: 'sse'` for\n * streams and, for paginated operations, `item` (the page's element type) and — on\n * result-mode clients only — `page` (the RAW page type `.pages()` yields, since\n * iteration unwraps the `Result` envelope the one-shot `result` carries).\n */\nexport type OpsShape = Record<\n string,\n {\n args: object;\n result: unknown;\n kind?: 'sse';\n item?: unknown;\n page?: unknown;\n /** Declared success-response headers for `{ envelope: true }` (camelCase keys). */\n headers?: object;\n /** Result-mode entries ignore the throw-only `envelope` option. */\n mode?: 'result';\n }\n>;\n\n/** The always-present client members (assigned after the operation loop — they win collisions). */\nexport type ClientCore = {\n /** Merge into the config; note `middleware` REPLACES the chain (use `use()` to compose). */\n configure(config: ClientConfig): void;\n /** Append interceptors (composes with baked/publisher middleware). */\n use(...middleware: Middleware[]): void;\n auth: {\n bearer(token: TokenProvider): void;\n basic(username: string, password: string): void;\n apiKey(scheme: string, value: TokenProvider): void;\n };\n};\n\n/**\n * The standard TypeScript optionality probe: `{}` has no required members, so\n * `{} extends A` is true exactly when every member of `A` is optional.\n */\n// oxlint-disable-next-line typescript/no-empty-object-type\ntype NoRequiredKeys = {} extends A ? true : false;\n\n/**\n * The page type `.pages()` yields: the RAW page declared by `page` (the generator\n * writes it only on result-mode paginated entries, whose `result` is the envelope),\n * or the method's own `result` (throw mode — already the raw page).\n */\ntype PageOf = Entry extends { page: unknown }\n ? Entry['page']\n : Entry['result'];\n\n/**\n * The auto-pagination members intersected onto a paginated method — present exactly when\n * the Ops entry declares `item` (the generator writes it only for paginated operations).\n * Args optionality mirrors the method's own; `unknown` otherwise (identity under `&`).\n * Iteration is error-mode-agnostic: `.pages()`/`.items()` yield raw pages/items, and a\n * failed page aborts iteration by throwing `ApiError`, even on result-mode clients; the\n * `onError` middleware hook (throw-mode-only) is not invoked.\n */\ntype Paginated = 'item' extends keyof Entry\n ? NoRequiredKeys extends true\n ? {\n pages(args?: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args?: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : {\n pages(args: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : unknown;\n\n/**\n * The stable identity every client method carries: the SPEC operationId (also set as\n * `fn.name`, but `operationId` is the explicit, minification-proof form) — a robust\n * cache key for consumer wrappers (react-query keys and the like).\n */\nexport type OperationMethodIdentity = { readonly operationId: string };\n\n/** Declared response-header bag for an Ops entry; empty object when none are declared. */\ntype HeadersOf = 'headers' extends keyof Entry\n ? NonNullable\n : Record;\n\n/**\n * Return type of a throw-mode call: the body by default, `Envelope<…>` for a literal\n * `envelope: true`, their union when the flag is a widened `boolean`. Exact\n * `RequestOptions` stays the body — pre-envelope package-mode flat sugar typed every\n * `init` parameter as `RequestOptions`, and widening that would break upgrades without\n * a regenerate. The `keyof` presence gate keeps `{ headers }` / `{ signal }` as the body\n * (`TInit['envelope']` through `TInit & RequestOptions` would otherwise be\n * `boolean | undefined`).\n */\nexport type EnvelopeResult<\n TData,\n THeaders,\n TInit extends RequestOptions | undefined,\n> = TInit extends undefined\n ? TData\n : RequestOptions extends TInit\n ? TInit extends RequestOptions\n ? TData\n : EnvelopeResultForKnownInit\n : EnvelopeResultForKnownInit;\n\ntype EnvelopeResultForKnownInit = 'envelope' extends keyof TInit\n ? [TInit['envelope' & keyof TInit]] extends [true]\n ? Envelope\n : [TInit['envelope' & keyof TInit]] extends [false | undefined]\n ? TData\n : TData | Envelope\n : TData;\n\n/** A one-shot method whose return shape never varies with per-call options. */\ntype BodyMethod =\n NoRequiredKeys extends true\n ? (args?: Entry['args'], init?: RequestOptions) => Promise\n : (args: Entry['args'], init?: RequestOptions) => Promise;\n\n/**\n * One-shot (non-SSE) method: default returns the body; `{ envelope: true }` returns\n * `{ data, headers, response }` with typed declared headers.\n */\ntype ThrowMethod =\n NoRequiredKeys extends true\n ? (\n args?: Entry['args'],\n init?: Init\n ) => Promise, Init>>\n : (\n args: Entry['args'],\n init?: Init\n ) => Promise, Init>>;\n\n/** The typed instance client: one bound method per operation plus the core members. */\nexport type Client = {\n [K in keyof Ops]: Ops[K] extends { kind: 'sse' }\n ? (NoRequiredKeys extends true\n ? (\n args?: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>\n : (\n args: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>) &\n OperationMethodIdentity\n : (Ops[K] extends { mode: 'result' } ? BodyMethod : ThrowMethod) &\n OperationMethodIdentity &\n Paginated;\n} & ClientCore;", + "/**\n * The public type surface of the client runtime — `@redocly/client-generator`'s\n * app-facing runtime module. Pure types, no runtime code (excluded from coverage).\n * The generator emits `OPERATIONS` literals typed\n * `satisfies Record` against this module, so an\n * incompatible runtime/generated pair fails the consumer's build (the semver skew guard).\n */\n\n/** How one operation parameter is sent: its location plus OpenAPI query-serialization hints. */\nexport type ParamSpec = {\n name: string;\n in: 'path' | 'query' | 'header' | 'cookie';\n style?: 'form' | 'spaceDelimited' | 'pipeDelimited' | 'deepObject';\n explode?: boolean;\n allowReserved?: boolean;\n};\n\n/** One security scheme, denormalized onto the operation (`scheme` names the spec's scheme). */\nexport type SecuritySpec =\n | { scheme: string; kind: 'bearer' | 'basic' }\n | { scheme: string; kind: 'apiKey'; name: string; in: 'header' | 'query' | 'cookie' };\n\n/**\n * How to auto-iterate a paginated operation (drives its `.pages()`/`.items()` members).\n * `nextCursor` and `items` are RFC 6901 JSON pointers into the page (response) value.\n */\nexport type PaginationSpec =\n | {\n style: 'cursor';\n /** The query param the iterator advances with the response's cursor. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the next cursor in the page. */\n nextCursor: string;\n /** Optional pointer to a boolean \"more pages\" flag — `false` stops iteration. */\n hasMore?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n style: 'offset' | 'page';\n /** The numeric query param the iterator advances. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n /** RFC 8288: follow the response's `Link` header `rel=\"next\"`; stop when absent. */\n style: 'link';\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n };\n\n/** The frozen data contract between generated code and the runtime: one operation's wire shape. */\nexport type OperationDescriptor = {\n id: string;\n method: string;\n path: string;\n tags?: readonly string[];\n params?: readonly ParamSpec[];\n /** `multipart: true` marks a typed object body serialized to FormData by the runtime. */\n body?: { contentType: string; multipart?: boolean };\n /** Defaults to `'json'` (content-type negotiation on parse). */\n responseKind?: 'json' | 'text' | 'blob' | 'void' | 'sse';\n sseDataKind?: 'json' | 'text';\n /** OR-alternatives, each an AND-set: the runtime applies the first fully-configured one. */\n security?: readonly (readonly SecuritySpec[])[];\n pagination?: PaginationSpec;\n /**\n * Declared success-response headers for throw-mode `{ envelope: true }`.\n * `name` is the lowercased wire name; `key` is the camelCase envelope property.\n */\n responseHeaders?: readonly ResponseHeaderSpec[];\n};\n\n/** One declared response header the runtime coerces into the envelope `headers` object. */\nexport type ResponseHeaderSpec = {\n name: string;\n key: string;\n type: 'string' | 'number' | 'boolean';\n};\n\n/** A query value: scalars, arrays of scalars, or objects (serialized as deepObject brackets). */\nexport type QueryValue =\n | string\n | number\n | boolean\n | null\n | undefined\n | Array\n | Record;\n\n/** A credential: a literal, or a (possibly async) function resolved per request (refresh flows). */\nexport type TokenProvider = string | (() => string | Promise);\n\n/** Per-instance credentials, keyed by the scheme kinds the runtime can inject. */\nexport type AuthCredentials = {\n bearer?: TokenProvider;\n basic?: { username: string; password: string };\n apiKey?: Record;\n};\n\n/** Backoff shape: 'fixed' = constant delay; 'exponential' = doubling per attempt. */\nexport type RetryStrategy = 'fixed' | 'exponential';\n\n/**\n * The operation's identity, exposed to middleware for targeting (`ctx.operation`).\n * Generated clients instantiate the type parameters with the spec's literal unions\n * (`OperationId`/`OperationPath`/`OperationTag`) so a misspelled operation id in a\n * middleware comparison fails to compile; the string defaults keep every\n * spec-independent consumer (`runtime-contract.ts`, the runtime internals) working\n * with the base shape. `tags` stays mutable (`Tag[]`) so setup-contract types\n * (byte-locked to generated output) remain assignable through middleware callbacks.\n */\nexport type OperationContext<\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n> = { id: Id; path: Path; tags: Tag[] };\n\n/** The mutable request context threaded through the middleware chain. */\nexport type RequestContext = {\n url: string;\n method: string;\n headers: Record;\n body?: unknown;\n operation: Op;\n};\n\n/** The failed attempt handed to a custom `retryOn`: exactly one of `response`/`error` is set. */\nexport type RetryContext = {\n attempt: number;\n request: RequestContext;\n response?: Response;\n error?: unknown;\n};\n\n/** Opt-in retry policy; a per-call override merges field-by-field over the config policy. */\nexport type RetryConfig = {\n retries?: number;\n retryDelay?: number;\n retryStrategy?: RetryStrategy;\n jitter?: boolean;\n retryOn?: (ctx: RetryContext) => boolean | Promise;\n};\n\n/**\n * Structural stand-in for the runtime's ApiError so this module stays import-free\n * (pure types); the real `ApiError` class is assignable to it.\n */\nexport type ApiErrorLike = globalThis.Error & {\n url: string;\n status: number;\n statusText: string;\n body: unknown;\n};\n\n/** One interceptor: any subset of the three hooks. */\nexport type Middleware = {\n onRequest?: (ctx: RequestContext) => void | Promise;\n onResponse?: (\n response: Response,\n ctx: RequestContext\n ) => Response | void | Promise;\n /** Throw mode only: may map/replace the error. */\n // `globalThis.Error` so a spec schema named `Error` cannot shadow it in inline mode.\n onError?: (\n error: ApiErrorLike,\n ctx: RequestContext\n ) => globalThis.Error | Promise;\n};\n\n/** Client configuration: transport, defaults, retry policy, middleware, and credentials. */\nexport type ClientConfig = {\n serverUrl?: string;\n fetch?: typeof fetch;\n headers?:\n | Record\n | (() => Record | Promise>);\n retry?: RetryConfig;\n /** Milliseconds before a request attempt aborts (covers the body read too; each retry\n * attempt gets a fresh budget). Per-call `timeout` overrides it, `0` disables it.\n * SSE streams are long-lived by design and never inherit this value. */\n timeout?: number;\n /** Send an `Idempotency-Key` header on POST/PATCH (one stable key per logical call,\n * reused across retry attempts) — which also makes those retries safe under the\n * default retry policy. `true` generates a UUID per call; a function supplies the key. */\n idempotencyKey?: boolean | (() => string);\n /** Identifies this client to the API via an `X-Redocly-Client` header (the generator\n * bakes a default). Sent only OUTSIDE browsers — a custom header would force a CORS\n * preflight. Override with your own value, or `false` to disable. */\n clientHeader?: string | false;\n middleware?: Middleware[];\n auth?: AuthCredentials;\n /** Fixed at generate time by the generator (`'throw'` when omitted); `configure()` ignores it. */\n errorMode?: 'throw' | 'result';\n /**\n * How each call spells its inputs: `'grouped'` (the default) namespaces them by layer —\n * `{ path, query, headers, cookies, body }` — and `'flat'` takes one merged object.\n * Fixed at generate time, like `errorMode`, because it shapes the static types.\n */\n argsStyle?: 'grouped' | 'flat';\n onRequest?: Middleware['onRequest'];\n onResponse?: Middleware['onResponse'];\n onError?: Middleware['onError'];\n};\n\n/** Response readers for the per-call `parseAs` override. */\nexport type ParseAs = 'auto' | 'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData' | 'stream';\n\n/** Per-call options: standard `RequestInit` plus a retry override, a timeout override\n * (`0` disables the config default), and a forced reader. */\nexport type RequestOptions = RequestInit & {\n retry?: RetryConfig;\n timeout?: number;\n /** Per-call idempotency key: a literal key, `true` to generate one, `false` to skip. */\n idempotencyKey?: string | boolean | (() => string);\n parseAs?: ParseAs;\n /**\n * Throw mode only: return `{ data, headers, response }` instead of the parsed body;\n * ignored in result mode. The explicit `| undefined` keeps the wrappers' emitted\n * `envelope: undefined` strip legal under `exactOptionalPropertyTypes`.\n */\n envelope?: boolean | undefined;\n};\n\n/** Throw-mode success envelope when `RequestOptions.envelope` is `true`. */\nexport type Envelope> = {\n data: TData;\n headers: THeaders;\n response: Response;\n};\n\n/** Per-call options for an SSE stream; reconnect defaults to true. */\nexport type SseOptions = RequestInit & { reconnect?: boolean; reconnectDelay?: number };\n\n/** A single decoded Server-Sent Event with its payload typed from the spec. */\nexport type ServerSentEvent = { event?: string; data: T; id?: string; retry?: number };\n\n/** Result-mode return shape: exactly one of `data`/`error` is set. */\nexport type Result =\n | { data: TData; error: undefined; response: Response }\n | { data: undefined; error: TError; response: Response };\n\n/**\n * The generated `Ops` type's shape: per-operation args/result, plus `kind: 'sse'` for\n * streams and, for paginated operations, `item` (the page's element type) and — on\n * result-mode clients only — `page` (the RAW page type `.pages()` yields, since\n * iteration unwraps the `Result` envelope the one-shot `result` carries).\n */\nexport type OpsShape = Record<\n string,\n {\n args: object;\n result: unknown;\n kind?: 'sse';\n item?: unknown;\n page?: unknown;\n /** Declared success-response headers for `{ envelope: true }` (camelCase keys). */\n headers?: object;\n /** Result-mode entries ignore the throw-only `envelope` option. */\n mode?: 'result';\n }\n>;\n\n/** The always-present client members (assigned after the operation loop — they win collisions). */\nexport type ClientCore = {\n /** Merge into the config; note `middleware` REPLACES the chain (use `use()` to compose). */\n configure(config: ClientConfig): void;\n /** Append interceptors (composes with baked/publisher middleware). */\n use(...middleware: Middleware[]): void;\n auth: {\n bearer(token: TokenProvider): void;\n basic(username: string, password: string): void;\n apiKey(scheme: string, value: TokenProvider): void;\n };\n};\n\n/**\n * The standard TypeScript optionality probe: `{}` has no required members, so\n * `{} extends A` is true exactly when every member of `A` is optional.\n */\n// oxlint-disable-next-line typescript/no-empty-object-type\ntype NoRequiredKeys = {} extends A ? true : false;\n\n/**\n * The page type `.pages()` yields: the RAW page declared by `page` (the generator\n * writes it only on result-mode paginated entries, whose `result` is the envelope),\n * or the method's own `result` (throw mode — already the raw page).\n */\ntype PageOf = Entry extends { page: unknown }\n ? Entry['page']\n : Entry['result'];\n\n/**\n * The auto-pagination members intersected onto a paginated method — present exactly when\n * the Ops entry declares `item` (the generator writes it only for paginated operations).\n * Args optionality mirrors the method's own; `unknown` otherwise (identity under `&`).\n * Iteration is error-mode-agnostic: `.pages()`/`.items()` yield raw pages/items, and a\n * failed page aborts iteration by throwing `ApiError`, even on result-mode clients; the\n * `onError` middleware hook (throw-mode-only) is not invoked.\n */\ntype Paginated = 'item' extends keyof Entry\n ? NoRequiredKeys extends true\n ? {\n pages(args?: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args?: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : {\n pages(args: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : unknown;\n\n/**\n * The stable identity every client method carries: the SPEC operationId (also set as\n * `fn.name`, but `operationId` is the explicit, minification-proof form) — a robust\n * cache key for consumer wrappers (react-query keys and the like).\n */\nexport type OperationMethodIdentity = { readonly operationId: string };\n\n/** Declared response-header bag for an Ops entry; empty object when none are declared. */\ntype HeadersOf = 'headers' extends keyof Entry\n ? NonNullable\n : Record;\n\n/**\n * Return type of a throw-mode call: the body by default, `Envelope<…>` for a literal\n * `envelope: true`, their union when the flag is a widened `boolean`. Exact\n * `RequestOptions` stays the body — pre-envelope package-mode flat sugar typed every\n * `init` parameter as `RequestOptions`, and widening that would break upgrades without\n * a regenerate. The `keyof` presence gate keeps `{ headers }` / `{ signal }` as the body\n * (`TInit['envelope']` through `TInit & RequestOptions` would otherwise be\n * `boolean | undefined`).\n */\nexport type EnvelopeResult<\n TData,\n THeaders,\n TInit extends RequestOptions | undefined,\n> = TInit extends undefined\n ? TData\n : RequestOptions extends TInit\n ? TInit extends RequestOptions\n ? TData\n : EnvelopeResultForKnownInit\n : EnvelopeResultForKnownInit;\n\ntype EnvelopeResultForKnownInit = 'envelope' extends keyof TInit\n ? [TInit['envelope' & keyof TInit]] extends [true]\n ? Envelope\n : [TInit['envelope' & keyof TInit]] extends [false | undefined]\n ? TData\n : TData | Envelope\n : TData;\n\n/** A one-shot method whose return shape never varies with per-call options. */\ntype BodyMethod =\n NoRequiredKeys extends true\n ? (args?: Entry['args'], init?: RequestOptions) => Promise\n : (args: Entry['args'], init?: RequestOptions) => Promise;\n\n/**\n * One-shot (non-SSE) method: default returns the body; `{ envelope: true }` returns\n * `{ data, headers, response }` with typed declared headers.\n */\ntype ThrowMethod =\n NoRequiredKeys extends true\n ? (\n args?: Entry['args'],\n init?: Init\n ) => Promise, Init>>\n : (\n args: Entry['args'],\n init?: Init\n ) => Promise, Init>>;\n\n/** The typed instance client: one bound method per operation plus the core members. */\nexport type Client = {\n [K in keyof Ops]: Ops[K] extends { kind: 'sse' }\n ? (NoRequiredKeys extends true\n ? (\n args?: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>\n : (\n args: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>) &\n OperationMethodIdentity\n : (Ops[K] extends { mode: 'result' } ? BodyMethod : ThrowMethod) &\n OperationMethodIdentity &\n Paginated;\n} & ClientCore;", 'errors.ts': "/** The error thrown (throw mode) for a non-2xx response, carrying the decoded error body. */\nexport class ApiError extends Error {\n public readonly url: string;\n public readonly status: number;\n public readonly statusText: string;\n public readonly body: unknown;\n constructor(url: string, status: number, statusText: string, body: unknown) {\n super(`Request failed with status ${status}`);\n this.name = 'ApiError';\n this.url = url;\n this.status = status;\n this.statusText = statusText;\n this.body = body;\n }\n}\n\n/** The error thrown when a request attempt exceeds the configured `timeout` — carries\n * the context a log line needs (which operation, what budget, which attempt). */\nexport class TimeoutError extends Error {\n public readonly operationId: string;\n public readonly timeout: number;\n public readonly attempt: number;\n constructor(operationId: string, timeout: number, attempt: number) {\n super(`Request \"${operationId}\" timed out after ${timeout} ms (attempt ${attempt})`);\n this.name = 'TimeoutError';\n this.operationId = operationId;\n this.timeout = timeout;\n this.attempt = attempt;\n }\n}\n\n/** The error to throw for an aborted request: the caller's abort reason when it is an Error. */\n// `globalThis.Error` (not bare `Error`) so a spec schema named `Error` cannot shadow it\n// when this module is embedded alongside generated types (inline mode).\nfunction abortError(signal: AbortSignal): globalThis.Error {\n const reason = (signal as { reason?: unknown }).reason;\n if (reason instanceof Error) return reason;\n return new DOMException('The operation was aborted.', 'AbortError');\n}", 'url.ts': @@ -51,11 +51,11 @@ export const RUNTIME_SOURCES_STRIPPED = { 'sse.ts': "/**\n * A frame delimiter: two consecutive line terminators (each CR, LF, or CRLF, per the SSE\n * spec — so mixed endings like `\\n\\r\\n` are valid boundaries, not just matching pairs).\n */\nconst FRAME_DELIMITER = /(?:\\r\\n|\\r|\\n){2}/;\n\n/**\n * A terminally malformed event stream — unparseable JSON `data` or an unbounded frame.\n * A stable bad payload, not a dropped connection, so the stream never reconnects on it.\n */\nclass SseParseError extends Error {}\n\n/**\n * Consume a `text/event-stream` operation as typed events (capability module — wired\n * into `createClient`). Auto-reconnects on dropped connections, resuming from the last\n * seen event id via `Last-Event-ID` (backoff: the server's `retry:` value, then\n * `reconnectDelay`, then 1s — exponential with jitter, capped at 30s). A clean stream\n * end flushes a trailing frame and finishes; `break`/abort end the iterator cleanly.\n */\nasync function* sse(\n config: ClientConfig,\n op: OperationContext,\n prepare: () => Promise<{ url: string; init: SseOptions; body?: unknown }>,\n dataKind: 'json' | 'text' = 'text'\n): AsyncGenerator> {\n let lastEventId: string | undefined;\n let serverRetry: number | undefined;\n let failures = 0;\n while (true) {\n // Re-prepare each attempt so a refresh-style TokenProvider yields a fresh credential\n // on reconnect (the auth is baked into `url` query + `init.headers`). `reconnect`,\n // `reconnectDelay`, and `signal` come from the caller's original options unchanged.\n const { url, init, body: requestBody } = await prepare();\n const { reconnect = true, reconnectDelay, ...rest } = init;\n const signal = rest.signal ?? undefined;\n if (signal?.aborted) return;\n const headers: Record = {\n Accept: 'text/event-stream',\n ...toHeaderRecord(rest.headers),\n };\n const sendHeaders =\n lastEventId === undefined ? headers : { ...headers, 'Last-Event-ID': lastEventId };\n try {\n const { response } = await send(\n config,\n op,\n url,\n // `timeout: 0` opts the stream out of a config-level timeout — an event stream\n // is long-lived by design and must not be severed after N milliseconds.\n { ...rest, method: rest.method ?? 'GET', headers: sendHeaders, timeout: 0 },\n requestBody,\n undefined,\n {}\n );\n if (!response.ok) {\n const errorBody = await readError(response);\n throw new ApiError(url, response.status, response.statusText, errorBody);\n }\n failures = 0;\n const body = response.body;\n if (!body) return;\n const reader = body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n try {\n while (true) {\n const { done, value } = await reader.read();\n buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });\n let match: RegExpExecArray | null;\n while ((match = FRAME_DELIMITER.exec(buffer)) !== null) {\n const raw = buffer.slice(0, match.index);\n buffer = buffer.slice(match.index + match[0].length);\n const event = parseSseFrame(raw, dataKind);\n if (event) {\n if (event.id !== undefined) lastEventId = event.id;\n if (event.retry !== undefined) serverRetry = event.retry;\n yield event as ServerSentEvent;\n }\n }\n if (done) {\n // Stream closed cleanly. Flush a final event that arrived without a trailing\n // delimiter, then finish — a clean end is not a dropped connection, so do not reconnect.\n const event = buffer.length > 0 ? parseSseFrame(buffer, dataKind) : undefined;\n if (event) {\n if (event.id !== undefined) lastEventId = event.id;\n if (event.retry !== undefined) serverRetry = event.retry;\n yield event as ServerSentEvent;\n }\n return;\n }\n // Bound memory: a server that never sends a frame delimiter would otherwise\n // grow `buffer` without limit. 1 MiB is far above any real SSE frame.\n if (buffer.length > 1048576) {\n throw new SseParseError('SSE frame exceeded 1048576 characters without a delimiter');\n }\n }\n } finally {\n await reader.cancel().catch(() => undefined);\n }\n } catch (error) {\n if (signal?.aborted) return;\n // A non-OK HTTP response (4xx/5xx) or an unparseable JSON payload is a definitive\n // error, not a transient drop — surface it instead of reconnecting in a loop (a\n // stable bad payload would otherwise reconnect forever).\n if (error instanceof ApiError || error instanceof SseParseError) throw error;\n // A transport failure (connect/DNS/reset) when opening the request, or a mid-stream\n // read error, is a dropped connection: fall through to backoff/reconnect when enabled.\n if (!reconnect) throw error;\n }\n // Only the swallowed-drop path reaches here: reconnect is on and the signal not aborted.\n failures++;\n const base = serverRetry ?? reconnectDelay ?? 1000;\n const delay = Math.min(base * Math.pow(2, failures - 1), 30_000);\n try {\n await sleep(Math.random() * delay, signal);\n } catch {\n return; // sleep rejects only on abort — end the iterator cleanly\n }\n }\n}\n\n/** Parse one raw SSE frame (its lines) into an event; returns undefined for comment-only frames. */\nfunction parseSseFrame(\n raw: string,\n dataKind: 'json' | 'text'\n): ServerSentEvent | undefined {\n let event: string | undefined;\n const dataLines: string[] = [];\n let id: string | undefined;\n let retry: number | undefined;\n let sawField = false;\n for (const line of raw.split(/\\r\\n|\\n|\\r/)) {\n if (line === '' || line.startsWith(':')) continue;\n const colon = line.indexOf(':');\n const field = colon === -1 ? line : line.slice(0, colon);\n let val = colon === -1 ? '' : line.slice(colon + 1);\n if (val.startsWith(' ')) val = val.slice(1);\n sawField = true;\n if (field === 'event') event = val;\n else if (field === 'data') dataLines.push(val);\n else if (field === 'id') id = val;\n else if (field === 'retry') {\n // ASCII digits only, per the EventSource spec — anything else is ignored\n // (`Number('')` is 0 and would zero the reconnect backoff).\n if (/^\\d+$/.test(val)) retry = Number(val);\n }\n }\n if (!sawField) return undefined;\n const dataText = dataLines.join('\\n');\n let data: unknown = dataText;\n if (dataKind === 'json' && dataText !== '') {\n try {\n data = JSON.parse(dataText);\n } catch (error) {\n throw new SseParseError(\n `Failed to parse SSE event data as JSON: ${error instanceof Error ? error.message : String(error)}`\n );\n }\n }\n return { event, data, id, retry };\n}", 'create-client.ts': - "/**\n * The optional behaviors `createClientCore` can dispatch to but never statically\n * imports. The package's public `createClient` wires the full set; the future\n * inline-mode assembler wires only the capabilities a spec needs.\n */\ntype Capabilities = SendCapabilities & {\n resolveAuth?: (\n security: readonly (readonly SecuritySpec[])[],\n config: ClientConfig\n ) => Promise<{ headers: Record; query: Record }>;\n sse?: (\n config: ClientConfig,\n op: OperationContext,\n // Re-preparing per (re)connect (not a frozen url/init) lets a refresh-style\n // TokenProvider issue a fresh credential after a dropped stream reconnects.\n prepare: () => Promise<{ url: string; init: SseOptions }>,\n dataKind: 'json' | 'text'\n ) => AsyncGenerator>;\n paginate?: {\n pages: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n items: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n // The `link`-style iterators need the raw `Link` header + page URL, which the\n // parsed-page call above cannot carry (the shape mirrors paginate's `LinkPageCall`).\n pagesByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n itemsByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n };\n};\n\n/** The grouped args wire shape: path params by name plus the `params`/`body`/`headers`/`cookies` slots. */\ntype OperationArgs = {\n params?: Record;\n body?: unknown;\n headers?: Record;\n cookies?: Record;\n} & Record;\n\n/** The response reader implied by the descriptor (before any per-call `parseAs` override). */\n/**\n * The `Accept` header matching how the response will be read — a blob/text operation\n * must not ask for `application/json` (a content-negotiating server would 406 or\n * answer with a JSON error body instead of the payload). Caller `init.headers` and\n * `config.headers` still override.\n */\nfunction acceptFor(kind: ParseAs | 'void'): string {\n if (kind === 'text') return 'text/*';\n if (kind === 'blob' || kind === 'arrayBuffer' || kind === 'stream' || kind === 'formData') {\n return '*/*';\n }\n return 'application/json'; // json | auto | void\n}\n\nfunction kindFor(op: OperationDescriptor): ParseAs | 'void' {\n if (op.responseKind === 'void' || op.responseKind === 'blob' || op.responseKind === 'text') {\n return op.responseKind;\n }\n return 'auto';\n}\n\n/** Route the grouped args by the descriptor: path values, query object, body, extra headers, cookies. */\nfunction splitArgs(op: OperationDescriptor, args: OperationArgs) {\n const path: Record = {};\n const pathNames = new Set();\n for (const param of op.params ?? []) {\n if (param.in === 'path') {\n pathNames.add(param.name);\n path[param.name] = args[param.name];\n }\n }\n // An unknown top-level key can only be a bug (usually a flat-style call shape passed\n // to a grouped client: `{ limit: 10 }` instead of `{ params: { limit: 10 } }`).\n // TypeScript catches it, but transpilers that skip type-checking would otherwise\n // ship a request that silently drops the value — fail the call loudly instead.\n for (const key of Object.keys(args)) {\n if (key === 'params' || key === 'body' || key === 'headers' || key === 'cookies') continue;\n if (pathNames.has(key)) continue;\n throw new TypeError(\n `Unknown argument \"${key}\" for operation \"${op.id}\". Query parameters go under params: { … } and the request body under body; valid keys are params, body, headers, cookies` +\n (pathNames.size > 0 ? `, and the path parameters (${[...pathNames].join(', ')}).` : '.')\n );\n }\n return {\n path,\n query: args.params,\n body: args.body,\n headers: args.headers,\n cookies: args.cookies,\n };\n}\n\n/**\n * The query-serialization hints for the descriptor's query params. A spec is built only\n * when the param deviates from the OpenAPI defaults (`form` + `explode: true`, encoded),\n * and always fully resolved — so `explode: false` or `allowReserved` alone (no `style`)\n * are honored, and an omitted `explode` keeps the exploded default.\n */\nfunction queryStyles(op: OperationDescriptor): Record | undefined {\n let styles: Record | undefined;\n for (const param of op.params ?? []) {\n if (param.in !== 'query') continue;\n const deviates =\n (param.style !== undefined && param.style !== 'form') ||\n param.explode === false ||\n param.allowReserved === true;\n if (!deviates) continue;\n styles ??= {};\n styles[param.name] = {\n style: param.style ?? 'form',\n explode: param.explode ?? true,\n allowReserved: param.allowReserved,\n };\n }\n return styles;\n}\n\n/** Stringify caller-supplied extra headers, skipping empty entries. */\nfunction stringHeaders(headers: Record | undefined): Record {\n const out: Record = {};\n for (const [key, value] of Object.entries(headers ?? {})) {\n if (value !== undefined && value !== null) out[key] = String(value);\n }\n return out;\n}\n\n/** Build the request pieces an attempt needs: the final URL and the merged per-call init. */\nasync function prepareRequest(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions | SseOptions,\n caps: Capabilities\n): Promise<{ url: string; init: RequestOptions; body: unknown }> {\n const { path, query, body, headers, cookies } = splitArgs(op, args);\n const authed: { headers: Record; query: Record } =\n op.security?.length && caps.resolveAuth\n ? await caps.resolveAuth(op.security, config)\n : { headers: {}, query: {} };\n // Cookie params join the auth-injected cookies in one `Cookie` header (values\n // percent-encoded, like auth cookies). Server-side only — browsers own the header.\n const cookiePairs = Object.entries(cookies ?? {})\n .filter(([, value]) => value !== undefined && value !== null)\n .map(([cookieName, value]) => `${cookieName}=${encodeURIComponent(String(value))}`);\n if (cookiePairs.length > 0) {\n authed.headers.Cookie = [authed.headers.Cookie, ...cookiePairs].filter(Boolean).join('; ');\n }\n const fullQuery: Record = { ...query, ...authed.query };\n const url = buildUrl(\n config.serverUrl ?? '',\n substitutePath(op.path, path),\n Object.keys(fullQuery).length > 0 ? fullQuery : undefined,\n queryStyles(op)\n );\n const mergedInit: RequestOptions = {\n ...init,\n method: op.method.toUpperCase(),\n // Precedence, lowest → highest (later spreads win): injected auth → explicit\n // header params → caller `init.headers` — the caller always overrides both.\n headers: {\n ...authed.headers,\n ...stringHeaders(headers),\n ...toHeaderRecord(init.headers),\n },\n };\n return { url, init: mergedInit, body };\n}\n\n/** Coerce a single declared response header value; omit when absent or unparsable. */\nfunction coerceResponseHeader(\n raw: string | null,\n type: ResponseHeaderSpec['type']\n): string | number | boolean | undefined {\n if (raw === null) return undefined;\n if (type === 'number') {\n if (raw.trim() === '') return undefined;\n const value = Number(raw);\n return Number.isFinite(value) ? value : undefined;\n }\n if (type === 'boolean') {\n const value = raw.trim().toLowerCase();\n if (value === 'true') return true;\n if (value === 'false') return false;\n return undefined;\n }\n return raw;\n}\n\n/** Build the camelCase declared-header bag for a throw-mode envelope. */\nfunction readEnvelopeHeaders(\n response: Response,\n specs: readonly ResponseHeaderSpec[] | undefined\n): Record {\n const headers: Record = {};\n for (const spec of specs ?? []) {\n const value = coerceResponseHeader(response.headers.get(spec.name), spec.type);\n if (value !== undefined) headers[spec.key] = value;\n }\n return headers;\n}\n\n/** One non-SSE call: send, then branch on the configured error mode. */\nasync function execute(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions,\n caps: Capabilities\n): Promise {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // `parseAs` / `envelope` are client options, not fetch RequestInit fields.\n const { parseAs, envelope, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const { response, context } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (config.errorMode === 'result') {\n if (!response.ok) {\n return { data: undefined, error: await readError(response), response };\n }\n return { data: await parse(response, readKind), error: undefined, response };\n }\n if (!response.ok) {\n let error: globalThis.Error = new ApiError(\n context.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n // Thread the error through each middleware's onError in turn (each may replace it).\n for (const mw of middlewareChain(config)) {\n if (mw.onError) error = await mw.onError(error as ApiErrorLike, context);\n }\n throw error;\n }\n const data = await parse(response, readKind);\n if (envelope === true) {\n return {\n data,\n headers: readEnvelopeHeaders(response, op.responseHeaders),\n response,\n };\n }\n return data;\n}\n\n/** The paginate capability, or a descriptive throw when a paginated op is iterated unwired. */\nfunction paginateCapability(caps: Capabilities, op: OperationDescriptor) {\n if (!caps.paginate) {\n throw new Error(`Pagination capability not wired: cannot iterate operation \"${op.id}\"`);\n }\n return caps.paginate;\n}\n\n/**\n * The per-page call the iterators drive: the method itself in throw mode; in result\n * mode a wrapper that unwraps the `{ data, error, response }` envelope — the page\n * pointers are data-rooted — rethrowing a failed page as `ApiError` (iteration is\n * error-mode-agnostic; the throw-mode-only `onError` middleware hook is not invoked).\n */\nfunction pageCall(\n method: (args?: OperationArgs, init?: RequestOptions) => Promise,\n config: ClientConfig\n) {\n const callWithoutEnvelope = (args?: OperationArgs, init?: RequestOptions) => {\n if (!init || init.envelope === undefined) return method(args, init);\n const { envelope: _envelope, ...pageInit } = init;\n return method(args, pageInit);\n };\n if (config.errorMode !== 'result') return callWithoutEnvelope;\n return async (args?: OperationArgs, init?: RequestOptions) => {\n const envelope = (await callWithoutEnvelope(args, init)) as {\n data: unknown;\n error: unknown;\n response: Response;\n };\n // Failure is `!response.ok` — NOT `data === undefined`: a successful bodyless page\n // (204/void) also parses to undefined data, and a failed page's `error` can be\n // undefined too (unreadable body). The pointers then miss on the undefined data\n // and iteration stops cleanly, which is the correct semantics for an empty page.\n if (!envelope.response.ok) {\n const { response } = envelope;\n throw new ApiError(response.url, response.status, response.statusText, envelope.error);\n }\n return envelope.data;\n };\n}\n\n/**\n * The per-page call the `link`-style iterators drive: like `execute`, but returning the\n * parsed page together with the raw `Link` header and the page's own URL (for resolving\n * a relative `rel=\"next\"` target). Error-mode-agnostic like all iteration: a failed\n * page throws `ApiError` even on result-mode clients.\n */\nfunction linkPageCall(config: ClientConfig, op: OperationDescriptor, caps: Capabilities) {\n return async (args: OperationArgs = {}, init: RequestOptions = {}) => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const { parseAs, envelope: _envelope, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n const { response } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (!response.ok) {\n throw new ApiError(\n prepared.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n }\n return {\n page: await parse(response, readKind),\n linkHeader: response.headers.get('link'),\n // Some `Response` implementations leave `url` empty (mocks, constructed responses).\n url: response.url === '' ? prepared.url : response.url,\n };\n };\n}\n\n/**\n * Build a typed instance client over operation descriptors: one real bound method per\n * operation (attached by a construction-time loop — no Proxy), plus the core members\n * (`configure`/`use`/`auth`), which are assigned AFTER the loop so they win any name\n * collision with an operation. All behavior dispatches through the capability seam.\n */\nfunction createClientCore<\n Ops extends OpsShape,\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n>(\n operations: Record,\n initial: ClientConfig> = {},\n caps: Capabilities = {}\n): Client> {\n // The literal-union narrowing is a compile-time DX contract only; internally the\n // runtime works with the base (string-typed) context. One cast at this boundary —\n // `ClientConfig` is not assignable to `ClientConfig` (middleware ctx\n // params are contravariant).\n const given = initial as ClientConfig;\n // Private mutable config; the middleware array is copied so `use()` never mutates the caller's.\n const config: ClientConfig = { ...given, middleware: [...(given.middleware ?? [])] };\n const client = {} as Record;\n\n for (const [name, op] of Object.entries(operations)) {\n if (op.responseKind === 'sse') {\n const method = (args: OperationArgs = {}, init: SseOptions = {}) => {\n if (!caps.sse) {\n throw new Error(`SSE capability not wired: cannot stream operation \"${op.id}\"`);\n }\n const stream = caps.sse;\n return (async function* () {\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // A thunk the stream re-runs on every (re)connect, so auth (which `prepareRequest`\n // resolves) is refreshed per attempt rather than frozen at the first connect.\n const prepare = async () => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n return { url: prepared.url, init: prepared.init as SseOptions, body: prepared.body };\n };\n yield* stream(config, opCtx, prepare, op.sseDataKind ?? 'text');\n })();\n };\n // Consumers key off the function reference (cache keys, `OPERATIONS[fn.name]`), so\n // each closure carries its operationId instead of an inferred binding name.\n // `operationId` is the explicit, minification-proof form of the same identity\n // (the SPEC operationId — `name` is the emitted key, which a collision may rename).\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n client[name] = method;\n } else {\n const method = (args: OperationArgs = {}, init: RequestOptions = {}) =>\n execute(config, op, args, init, caps);\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n const spec = op.pagination;\n // Paginated ops keep their one-shot call and gain `.pages`/`.items`, dispatching\n // through the capability seam (like SSE: absent capability throws descriptively).\n // Iteration is error-mode-agnostic: the iterators' pointers are data-rooted, so on\n // a result-mode client (`errorMode` is fixed at construction — `configure()`\n // ignores it) each page's envelope is unwrapped before it reaches the capability.\n // A failed page aborts iteration by throwing ApiError, even on result-mode\n // clients; the `onError` middleware hook (throw-mode-only) is not invoked.\n client[name] =\n spec === undefined\n ? method\n : spec.style === 'link'\n ? Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pagesByLink(\n linkPageCall(config, op, caps),\n args,\n init\n ),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).itemsByLink(\n linkPageCall(config, op, caps),\n spec,\n args,\n init\n ),\n })\n : Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pages(pageCall(method, config), spec, args, init),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).items(pageCall(method, config), spec, args, init),\n });\n }\n }\n\n // Core members are assigned AFTER the operation loop — they win over colliding op names.\n client.configure = (next: ClientConfig): void => {\n // `errorMode` is fixed at generate time (it shapes the static types); flipping it at\n // runtime would silently desync return shapes from `Client`, so it is ignored.\n const { errorMode: _fixed, auth, ...rest } = next;\n Object.assign(config, rest);\n // `auth` merges into existing credentials (like the `auth.*` setters) rather than\n // replacing wholesale — so `configure({ auth: { bearer } })` keeps a previously set\n // basic/apiKey. `apiKey` merges per scheme.\n if (auth) {\n config.auth = {\n ...config.auth,\n ...auth,\n ...(auth.apiKey ? { apiKey: { ...config.auth?.apiKey, ...auth.apiKey } } : {}),\n };\n }\n };\n client.use = (...middleware: Middleware[]): void => {\n // Reassign (don't push) so a caller-provided `middleware` array isn't mutated.\n config.middleware = [...(config.middleware ?? []), ...middleware];\n };\n client.auth = {\n bearer(token: TokenProvider): void {\n config.auth = { ...config.auth, bearer: token };\n },\n basic(username: string, password: string): void {\n config.auth = { ...config.auth, basic: { username, password } };\n },\n apiKey(scheme: string, value: TokenProvider): void {\n config.auth = { ...config.auth, apiKey: { ...config.auth?.apiKey, [scheme]: value } };\n },\n };\n\n return client as Client>;\n}", + "/**\n * The optional behaviors `createClientCore` can dispatch to but never statically\n * imports. The package's public `createClient` wires the full set; the future\n * inline-mode assembler wires only the capabilities a spec needs.\n */\ntype Capabilities = SendCapabilities & {\n resolveAuth?: (\n security: readonly (readonly SecuritySpec[])[],\n config: ClientConfig\n ) => Promise<{ headers: Record; query: Record }>;\n sse?: (\n config: ClientConfig,\n op: OperationContext,\n // Re-preparing per (re)connect (not a frozen url/init) lets a refresh-style\n // TokenProvider issue a fresh credential after a dropped stream reconnects.\n prepare: () => Promise<{ url: string; init: SseOptions }>,\n dataKind: 'json' | 'text'\n ) => AsyncGenerator>;\n paginate?: {\n pages: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n items: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n // The `link`-style iterators need the raw `Link` header + page URL, which the\n // parsed-page call above cannot carry (the shape mirrors paginate's `LinkPageCall`).\n pagesByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n itemsByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n };\n};\n\n/**\n * One call's inputs, namespaced by transport layer. `argsStyle: 'flat'` clients accept the\n * merged form instead (every parameter and body property at one level) — `namespaceArgs`\n * converts it to this shape before anything downstream reads it.\n */\ntype OperationArgs = {\n path?: Record;\n query?: Record;\n body?: unknown;\n headers?: Record;\n cookies?: Record;\n} & Record;\n\n/** The five layer keys, and the only top-level keys a namespaced call may carry. */\nconst LAYERS: readonly string[] = ['path', 'query', 'body', 'headers', 'cookies'];\n\n/** Where a declared parameter's `in` value puts it. */\nconst LAYER_OF: Record = {\n path: 'path',\n query: 'query',\n header: 'headers',\n cookie: 'cookies',\n};\n\n/**\n * Merged (`argsStyle: 'flat'`) args → the namespaced shape. A key that names a declared\n * parameter goes to that parameter's layer; anything else is a property of the request\n * body, which is how a flat call spells an object body. `body` stays reserved for the\n * operations a flat call cannot merge (an array, a scalar, or a binary body).\n */\nfunction namespaceArgs(op: OperationDescriptor, args: OperationArgs): OperationArgs {\n const layers: Record> = {};\n let body: unknown;\n let properties: Record | undefined;\n const layerOfParam = new Map((op.params ?? []).map((param) => [param.name, param.in]));\n for (const [key, value] of Object.entries(args)) {\n const layer = LAYER_OF[layerOfParam.get(key) ?? ''];\n if (layer !== undefined) {\n (layers[layer] ??= {})[key] = value;\n } else if (key === 'body' && op.body !== undefined) {\n body = value;\n } else if (op.body !== undefined) {\n (properties ??= {})[key] = value;\n } else {\n throw new TypeError(\n `Unknown argument \"${key}\" for operation \"${op.id}\": it names no declared parameter, and the operation takes no request body.`\n );\n }\n }\n const namespaced: OperationArgs = {};\n if (layers.path) namespaced.path = layers.path;\n // The flat surface types every query value, so the collected bag is one by construction.\n if (layers.query) namespaced.query = layers.query as Record;\n if (layers.headers) namespaced.headers = layers.headers;\n if (layers.cookies) namespaced.cookies = layers.cookies;\n if (properties !== undefined) namespaced.body = properties;\n else if (body !== undefined) namespaced.body = body;\n return namespaced;\n}\n\n/** The response reader implied by the descriptor (before any per-call `parseAs` override). */\n/**\n * The `Accept` header matching how the response will be read — a blob/text operation\n * must not ask for `application/json` (a content-negotiating server would 406 or\n * answer with a JSON error body instead of the payload). Caller `init.headers` and\n * `config.headers` still override.\n */\nfunction acceptFor(kind: ParseAs | 'void'): string {\n if (kind === 'text') return 'text/*';\n if (kind === 'blob' || kind === 'arrayBuffer' || kind === 'stream' || kind === 'formData') {\n return '*/*';\n }\n return 'application/json'; // json | auto | void\n}\n\nfunction kindFor(op: OperationDescriptor): ParseAs | 'void' {\n if (op.responseKind === 'void' || op.responseKind === 'blob' || op.responseKind === 'text') {\n return op.responseKind;\n }\n return 'auto';\n}\n\n/** The call's inputs in namespaced form, converting first on a flat-style client. */\nfunction inputOf(\n op: OperationDescriptor,\n args: OperationArgs,\n config: ClientConfig\n): OperationArgs {\n return config.argsStyle === 'flat' ? namespaceArgs(op, args) : args;\n}\n\n/** Route the namespaced args to the request pieces. */\nfunction splitArgs(op: OperationDescriptor, args: OperationArgs) {\n // An unknown layer key can only be a bug (usually flat-style args on a namespaced\n // client). TypeScript catches it, but a transpiler that skips type-checking would\n // otherwise ship a request that silently drops the value — fail the call loudly.\n for (const key of Object.keys(args)) {\n if (!LAYERS.includes(key)) {\n throw new TypeError(\n `Unknown argument \"${key}\" for operation \"${op.id}\". Inputs are grouped by layer: ${LAYERS.join(', ')}.`\n );\n }\n }\n return {\n path: args.path ?? {},\n query: args.query,\n body: args.body,\n headers: args.headers,\n cookies: args.cookies,\n };\n}\n\n/**\n * The query-serialization hints for the descriptor's query params. A spec is built only\n * when the param deviates from the OpenAPI defaults (`form` + `explode: true`, encoded),\n * and always fully resolved — so `explode: false` or `allowReserved` alone (no `style`)\n * are honored, and an omitted `explode` keeps the exploded default.\n */\nfunction queryStyles(op: OperationDescriptor): Record | undefined {\n let styles: Record | undefined;\n for (const param of op.params ?? []) {\n if (param.in !== 'query') continue;\n const deviates =\n (param.style !== undefined && param.style !== 'form') ||\n param.explode === false ||\n param.allowReserved === true;\n if (!deviates) continue;\n styles ??= {};\n styles[param.name] = {\n style: param.style ?? 'form',\n explode: param.explode ?? true,\n allowReserved: param.allowReserved,\n };\n }\n return styles;\n}\n\n/** Stringify caller-supplied extra headers, skipping empty entries. */\nfunction stringHeaders(headers: Record | undefined): Record {\n const out: Record = {};\n for (const [key, value] of Object.entries(headers ?? {})) {\n if (value !== undefined && value !== null) out[key] = String(value);\n }\n return out;\n}\n\n/** Build the request pieces an attempt needs: the final URL and the merged per-call init. */\nasync function prepareRequest(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions | SseOptions,\n caps: Capabilities\n): Promise<{ url: string; init: RequestOptions; body: unknown }> {\n const { path, query, body, headers, cookies } = splitArgs(op, args);\n const authed: { headers: Record; query: Record } =\n op.security?.length && caps.resolveAuth\n ? await caps.resolveAuth(op.security, config)\n : { headers: {}, query: {} };\n // Cookie params join the auth-injected cookies in one `Cookie` header (values\n // percent-encoded, like auth cookies). Server-side only — browsers own the header.\n const cookiePairs = Object.entries(cookies ?? {})\n .filter(([, value]) => value !== undefined && value !== null)\n .map(([cookieName, value]) => `${cookieName}=${encodeURIComponent(String(value))}`);\n if (cookiePairs.length > 0) {\n authed.headers.Cookie = [authed.headers.Cookie, ...cookiePairs].filter(Boolean).join('; ');\n }\n const fullQuery: Record = { ...query, ...authed.query };\n const url = buildUrl(\n config.serverUrl ?? '',\n substitutePath(op.path, path),\n Object.keys(fullQuery).length > 0 ? fullQuery : undefined,\n queryStyles(op)\n );\n const mergedInit: RequestOptions = {\n ...init,\n method: op.method.toUpperCase(),\n // Precedence, lowest → highest (later spreads win): injected auth → explicit\n // header params → caller `init.headers` — the caller always overrides both.\n headers: {\n ...authed.headers,\n ...stringHeaders(headers),\n ...toHeaderRecord(init.headers),\n },\n };\n return { url, init: mergedInit, body };\n}\n\n/** Coerce a single declared response header value; omit when absent or unparsable. */\nfunction coerceResponseHeader(\n raw: string | null,\n type: ResponseHeaderSpec['type']\n): string | number | boolean | undefined {\n if (raw === null) return undefined;\n if (type === 'number') {\n if (raw.trim() === '') return undefined;\n const value = Number(raw);\n return Number.isFinite(value) ? value : undefined;\n }\n if (type === 'boolean') {\n const value = raw.trim().toLowerCase();\n if (value === 'true') return true;\n if (value === 'false') return false;\n return undefined;\n }\n return raw;\n}\n\n/** Build the camelCase declared-header bag for a throw-mode envelope. */\nfunction readEnvelopeHeaders(\n response: Response,\n specs: readonly ResponseHeaderSpec[] | undefined\n): Record {\n const headers: Record = {};\n for (const spec of specs ?? []) {\n const value = coerceResponseHeader(response.headers.get(spec.name), spec.type);\n if (value !== undefined) headers[spec.key] = value;\n }\n return headers;\n}\n\n/** One non-SSE call: send, then branch on the configured error mode. */\nasync function execute(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions,\n caps: Capabilities\n): Promise {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // `parseAs` / `envelope` are client options, not fetch RequestInit fields.\n const { parseAs, envelope, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const { response, context } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (config.errorMode === 'result') {\n if (!response.ok) {\n return { data: undefined, error: await readError(response), response };\n }\n return { data: await parse(response, readKind), error: undefined, response };\n }\n if (!response.ok) {\n let error: globalThis.Error = new ApiError(\n context.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n // Thread the error through each middleware's onError in turn (each may replace it).\n for (const mw of middlewareChain(config)) {\n if (mw.onError) error = await mw.onError(error as ApiErrorLike, context);\n }\n throw error;\n }\n const data = await parse(response, readKind);\n if (envelope === true) {\n return {\n data,\n headers: readEnvelopeHeaders(response, op.responseHeaders),\n response,\n };\n }\n return data;\n}\n\n/** The paginate capability, or a descriptive throw when a paginated op is iterated unwired. */\nfunction paginateCapability(caps: Capabilities, op: OperationDescriptor) {\n if (!caps.paginate) {\n throw new Error(`Pagination capability not wired: cannot iterate operation \"${op.id}\"`);\n }\n return caps.paginate;\n}\n\n/**\n * The per-page call the iterators drive: the method itself in throw mode; in result\n * mode a wrapper that unwraps the `{ data, error, response }` envelope — the page\n * pointers are data-rooted — rethrowing a failed page as `ApiError` (iteration is\n * error-mode-agnostic; the throw-mode-only `onError` middleware hook is not invoked).\n */\nfunction pageCall(\n method: (args?: OperationArgs, init?: RequestOptions) => Promise,\n config: ClientConfig\n) {\n const callWithoutEnvelope = (args?: OperationArgs, init?: RequestOptions) => {\n if (!init || init.envelope === undefined) return method(args, init);\n const { envelope: _envelope, ...pageInit } = init;\n return method(args, pageInit);\n };\n if (config.errorMode !== 'result') return callWithoutEnvelope;\n return async (args?: OperationArgs, init?: RequestOptions) => {\n const envelope = (await callWithoutEnvelope(args, init)) as {\n data: unknown;\n error: unknown;\n response: Response;\n };\n // Failure is `!response.ok` — NOT `data === undefined`: a successful bodyless page\n // (204/void) also parses to undefined data, and a failed page's `error` can be\n // undefined too (unreadable body). The pointers then miss on the undefined data\n // and iteration stops cleanly, which is the correct semantics for an empty page.\n if (!envelope.response.ok) {\n const { response } = envelope;\n throw new ApiError(response.url, response.status, response.statusText, envelope.error);\n }\n return envelope.data;\n };\n}\n\n/**\n * The per-page call the `link`-style iterators drive: like `execute`, but returning the\n * parsed page together with the raw `Link` header and the page's own URL (for resolving\n * a relative `rel=\"next\"` target). Error-mode-agnostic like all iteration: a failed\n * page throws `ApiError` even on result-mode clients.\n */\nfunction linkPageCall(config: ClientConfig, op: OperationDescriptor, caps: Capabilities) {\n return async (args: OperationArgs = {}, init: RequestOptions = {}) => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const { parseAs, envelope: _envelope, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n const { response } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (!response.ok) {\n throw new ApiError(\n prepared.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n }\n return {\n page: await parse(response, readKind),\n linkHeader: response.headers.get('link'),\n // Some `Response` implementations leave `url` empty (mocks, constructed responses).\n url: response.url === '' ? prepared.url : response.url,\n };\n };\n}\n\n/**\n * Build a typed instance client over operation descriptors: one real bound method per\n * operation (attached by a construction-time loop — no Proxy), plus the core members\n * (`configure`/`use`/`auth`), which are assigned AFTER the loop so they win any name\n * collision with an operation. All behavior dispatches through the capability seam.\n */\nfunction createClientCore<\n Ops extends OpsShape,\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n>(\n operations: Record,\n initial: ClientConfig> = {},\n caps: Capabilities = {}\n): Client> {\n // The literal-union narrowing is a compile-time DX contract only; internally the\n // runtime works with the base (string-typed) context. One cast at this boundary —\n // `ClientConfig` is not assignable to `ClientConfig` (middleware ctx\n // params are contravariant).\n const given = initial as ClientConfig;\n // Private mutable config; the middleware array is copied so `use()` never mutates the caller's.\n const config: ClientConfig = { ...given, middleware: [...(given.middleware ?? [])] };\n const client = {} as Record;\n\n for (const [name, op] of Object.entries(operations)) {\n if (op.responseKind === 'sse') {\n const method = (given: OperationArgs = {}, init: SseOptions = {}) => {\n const args = inputOf(op, given, config);\n if (!caps.sse) {\n throw new Error(`SSE capability not wired: cannot stream operation \"${op.id}\"`);\n }\n const stream = caps.sse;\n return (async function* () {\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // A thunk the stream re-runs on every (re)connect, so auth (which `prepareRequest`\n // resolves) is refreshed per attempt rather than frozen at the first connect.\n const prepare = async () => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n return { url: prepared.url, init: prepared.init as SseOptions, body: prepared.body };\n };\n yield* stream(config, opCtx, prepare, op.sseDataKind ?? 'text');\n })();\n };\n // Consumers key off the function reference (cache keys, `OPERATIONS[fn.name]`), so\n // each closure carries its operationId instead of an inferred binding name.\n // `operationId` is the explicit, minification-proof form of the same identity\n // (the SPEC operationId — `name` is the emitted key, which a collision may rename).\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n client[name] = method;\n } else {\n // `raw` takes namespaced args; `method` is the public entry that accepts whichever\n // style the client was generated with. The iterators namespace once and then drive\n // `raw`, so a flat call is never converted twice.\n const raw = (args: OperationArgs = {}, init: RequestOptions = {}) =>\n execute(config, op, args, init, caps);\n const method = (args: OperationArgs = {}, init: RequestOptions = {}) =>\n raw(inputOf(op, args, config), init);\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n const spec = op.pagination;\n // Paginated ops keep their one-shot call and gain `.pages`/`.items`, dispatching\n // through the capability seam (like SSE: absent capability throws descriptively).\n // Iteration is error-mode-agnostic: the iterators' pointers are data-rooted, so on\n // a result-mode client (`errorMode` is fixed at construction — `configure()`\n // ignores it) each page's envelope is unwrapped before it reaches the capability.\n // A failed page aborts iteration by throwing ApiError, even on result-mode\n // clients; the `onError` middleware hook (throw-mode-only) is not invoked.\n client[name] =\n spec === undefined\n ? method\n : spec.style === 'link'\n ? Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pagesByLink(\n linkPageCall(config, op, caps),\n inputOf(op, args ?? {}, config),\n init\n ),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).itemsByLink(\n linkPageCall(config, op, caps),\n spec,\n inputOf(op, args ?? {}, config),\n init\n ),\n })\n : Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pages(\n pageCall(raw, config),\n spec,\n inputOf(op, args ?? {}, config),\n init\n ),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).items(\n pageCall(raw, config),\n spec,\n inputOf(op, args ?? {}, config),\n init\n ),\n });\n }\n }\n\n // Core members are assigned AFTER the operation loop — they win over colliding op names.\n client.configure = (next: ClientConfig): void => {\n // `errorMode` and `argsStyle` are fixed at generate time (they shape the static types);\n // flipping either at runtime would silently desync the calls from `Client`, so both\n // are ignored here.\n const { errorMode: _fixedMode, argsStyle: _fixedStyle, auth, ...rest } = next;\n Object.assign(config, rest);\n // `auth` merges into existing credentials (like the `auth.*` setters) rather than\n // replacing wholesale — so `configure({ auth: { bearer } })` keeps a previously set\n // basic/apiKey. `apiKey` merges per scheme.\n if (auth) {\n config.auth = {\n ...config.auth,\n ...auth,\n ...(auth.apiKey ? { apiKey: { ...config.auth?.apiKey, ...auth.apiKey } } : {}),\n };\n }\n };\n client.use = (...middleware: Middleware[]): void => {\n // Reassign (don't push) so a caller-provided `middleware` array isn't mutated.\n config.middleware = [...(config.middleware ?? []), ...middleware];\n };\n client.auth = {\n bearer(token: TokenProvider): void {\n config.auth = { ...config.auth, bearer: token };\n },\n basic(username: string, password: string): void {\n config.auth = { ...config.auth, basic: { username, password } };\n },\n apiKey(scheme: string, value: TokenProvider): void {\n config.auth = { ...config.auth, apiKey: { ...config.auth?.apiKey, [scheme]: value } };\n },\n };\n\n return client as Client>;\n}", 'paginate.ts': - "/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `params` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nfunction resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `params[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nasync function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.params?.[spec.param];\n while (true) {\n const params = { ...args.params };\n if (cursor !== undefined) params[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, params }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `params[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.params?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call(\n { ...args, params: { ...args.params, [spec.param]: position } },\n init\n );\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nasync function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\ntype LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nfunction linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nasync function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let params = args.params;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, params }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n params = { ...args.params, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nasync function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}", + "/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `query` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nfunction resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `query[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nasync function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.query?.[spec.param];\n while (true) {\n const query = { ...args.query };\n if (cursor !== undefined) query[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, query }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `query[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.query?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call({ ...args, query: { ...args.query, [spec.param]: position } }, init);\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nasync function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\ntype LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nfunction linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nasync function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let query = args.query;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, query }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n query = { ...args.query, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nasync function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}", 'cli.ts': - "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\ntype CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\ntype CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /** Present when the operation takes a JSON request body. */\n body?: { required: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\ntype CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\ntype CliWiring = {\n binName: string;\n /** Credential variable prefix. Defaults to `binName`, constant-cased — set it when the\n * displayed name and the credential family must differ (a composed multi-API binary). */\n envPrefix?: string;\n /** The generated instance client (grouped-args methods). */\n client: Record;\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\ntype CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\ntype CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\ntype CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\ntype CommandSource = {\n namespace?: string;\n commands: Array;\n /** Absent = inherit the first wired source's — a root `login` shares the binary's identity. */\n wiring?: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nfunction groupSlug(group: string): string {\n return group\n .toLowerCase()\n .split(/[^a-z0-9]+/)\n .filter(Boolean)\n .join('-');\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nfunction parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n // An untagged operation is only ever addressed by its bare name, so when that name is also\n // a group slug the name wins — reading it as the group would leave the command unreachable.\n // A tagged operation in the same position keeps yielding to group help: it is still\n // reachable as ` `.\n const untagged = commands.some((c) => c.group === undefined && c.name === argv[0]);\n let command: CliCommand | undefined;\n let rest: string[];\n if (!untagged && slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nfunction envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nasync function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nasync function runCli(sources: CommandSource[], argv: string[]): Promise;\nasync function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // A source without wiring inherits the first wired one, so the documented root-source\n // shape `{ commands: [login] }` works: the login shares the composed binary's identity.\n const inherited = sources.find((source) => source.wiring !== undefined)?.wiring;\n const wiringOf = (source: CommandSource): CliWiring => source.wiring ?? (inherited as CliWiring);\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = wiringOf(sources[0]);\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.binName)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, wiringOf(source), argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, wiringOf(root as CommandSource), argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], binName: string): string[] {\n const lines = [`Usage: ${binName} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${binName} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n for (const line of renderHelp(\n commands,\n wiring.binName,\n wiring.schemes ?? [],\n prefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n // Redaction matches these against header VALUES — so basic auth must contribute the\n // form the wire actually carries (`Basic ${base64(user:pass)}`), not just the raw\n // password, which never appears in the encoded header.\n const basic = auth.basic as { username: string; password: string } | undefined;\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(basic ? [basic.password, btoa(`${basic.username}:${basic.password}`)] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const variables: Record = { ...positionals };\n if (Object.keys(params).length > 0) variables.params = params;\n if (body !== undefined) variables.body = body;\n const argument = Object.keys(variables).length > 0 ? variables : undefined;\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n // An SSE method returns a lazy stream — drain the stubbed response so its fetch\n // actually runs and captures the request.\n if (command.sse) for await (const _event of result as AsyncIterable);\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}", + "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\ntype CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\ntype CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /**\n * Present when the operation takes a JSON request body. `merged` marks a body whose own\n * properties a flat-style call spells at the top level (the generator decides this from\n * the schema, so the CLI and the client can never disagree).\n */\n body?: { required: boolean; merged?: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\ntype CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\ntype CliWiring = {\n binName: string;\n /** Credential variable prefix. Defaults to `binName`, constant-cased — set it when the\n * displayed name and the credential family must differ (a composed multi-API binary). */\n envPrefix?: string;\n /** The generated instance client. */\n client: Record;\n /** How that client takes its inputs. Defaults to `'grouped'`, the generated default. */\n argsStyle?: 'grouped' | 'flat';\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\ntype CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\ntype CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\ntype CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\ntype CommandSource = {\n namespace?: string;\n commands: Array;\n /** Absent = inherit the first wired source's — a root `login` shares the binary's identity. */\n wiring?: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nfunction groupSlug(group: string): string {\n return group\n .toLowerCase()\n .split(/[^a-z0-9]+/)\n .filter(Boolean)\n .join('-');\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/**\n * The parsed argv as one call input, in the style the wired client takes: grouped by layer\n * (the default) or merged into one object.\n */\nfunction callInputs(\n command: CliCommand,\n positionals: Record,\n params: Record,\n body: unknown,\n argsStyle: CliWiring['argsStyle']\n): Record | undefined {\n const inputs: Record = {};\n if (argsStyle === 'flat') {\n Object.assign(inputs, positionals, params);\n if (body !== undefined) {\n if (command.body?.merged === true) Object.assign(inputs, body as Record);\n else inputs.body = body;\n }\n } else {\n if (Object.keys(positionals).length > 0) inputs.path = positionals;\n if (Object.keys(params).length > 0) inputs.query = params;\n if (body !== undefined) inputs.body = body;\n }\n return Object.keys(inputs).length > 0 ? inputs : undefined;\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nfunction parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n // An untagged operation is only ever addressed by its bare name, so when that name is also\n // a group slug the name wins — reading it as the group would leave the command unreachable.\n // A tagged operation in the same position keeps yielding to group help: it is still\n // reachable as ` `.\n const untagged = commands.some((c) => c.group === undefined && c.name === argv[0]);\n let command: CliCommand | undefined;\n let rest: string[];\n if (!untagged && slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nfunction envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nasync function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nasync function runCli(sources: CommandSource[], argv: string[]): Promise;\nasync function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // A source without wiring inherits the first wired one, so the documented root-source\n // shape `{ commands: [login] }` works: the login shares the composed binary's identity.\n const inherited = sources.find((source) => source.wiring !== undefined)?.wiring;\n const wiringOf = (source: CommandSource): CliWiring => source.wiring ?? (inherited as CliWiring);\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = wiringOf(sources[0]);\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.binName)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, wiringOf(source), argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, wiringOf(root as CommandSource), argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], binName: string): string[] {\n const lines = [`Usage: ${binName} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${binName} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n for (const line of renderHelp(\n commands,\n wiring.binName,\n wiring.schemes ?? [],\n prefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n // Redaction matches these against header VALUES — so basic auth must contribute the\n // form the wire actually carries (`Basic ${base64(user:pass)}`), not just the raw\n // password, which never appears in the encoded header.\n const basic = auth.basic as { username: string; password: string } | undefined;\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(basic ? [basic.password, btoa(`${basic.username}:${basic.password}`)] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const argument = callInputs(command, positionals, params, body, wiring.argsStyle);\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n // An SSE method returns a lazy stream — drain the stubbed response so its fetch\n // actually runs and captures the request.\n if (command.sse) for await (const _event of result as AsyncIterable);\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}", } as const; export type RuntimeModuleName = keyof typeof RUNTIME_SOURCES; @@ -87,6 +87,8 @@ export const RUNTIME_DECLARED_NAMES = [ 'GLOBAL_FLAGS', 'HeadersOf', 'IDEMPOTENT_METHODS', + 'LAYERS', + 'LAYER_OF', 'LinkPageCall', 'Middleware', 'NoRequiredKeys', @@ -122,6 +124,7 @@ export const RUNTIME_DECLARED_NAMES = [ 'abortError', 'acceptFor', 'buildUrl', + 'callInputs', 'coerceResponseHeader', 'commandContract', 'createClientCore', @@ -131,6 +134,7 @@ export const RUNTIME_DECLARED_NAMES = [ 'envPrefix', 'execute', 'groupSlug', + 'inputOf', 'isConfigured', 'items', 'itemsByLink', @@ -140,6 +144,7 @@ export const RUNTIME_DECLARED_NAMES = [ 'loadBody', 'mergeSetup', 'middlewareChain', + 'namespaceArgs', 'normalizeCommands', 'oneLine', 'pageCall', diff --git a/packages/client-generator/src/emitters/swr.ts b/packages/client-generator/src/emitters/swr.ts index 9ff2a142e2..4a218db0fc 100644 --- a/packages/client-generator/src/emitters/swr.ts +++ b/packages/client-generator/src/emitters/swr.ts @@ -23,7 +23,6 @@ export type SwrOptions = { /** Import specifier for the sdk entry the operation functions/types live in. */ sdkModule: string; /** How the sdk function takes its inputs — must match the generated client. */ - argsStyle: 'flat' | 'grouped'; }; /** Render the full SWR module source. `''` when there are no wrappable operations. */ @@ -36,7 +35,7 @@ export function renderSwrModule(model: ApiModel, opts: SwrOptions): string { ...(hasQuery ? ['import useSWR from "swr";'] : []), ...(hasMutation ? ['import useSWRMutation from "swr/mutation";'] : []), sdkNamedImportText(ops, opts.sdkModule, hasQuery), - ...ops.flatMap((op) => (isQuery(op) ? queryBlocks(op, opts) : [mutationBlock(op, opts)])), + ...ops.flatMap((op) => (isQuery(op) ? queryBlocks(op) : [mutationBlock(op)])), ]; return blocks.join('\n\n'); } @@ -47,7 +46,7 @@ function hookBlock(op: OperationModel, params: string, expr: string): string { } /** A query op's `Key` factory + `use` hook calling `useSWR`. */ -function queryBlocks(op: OperationModel, opts: SwrOptions): string[] { +function queryBlocks(op: OperationModel): string[] { const inputs = hasInputs(op); const keyParams = inputs ? `vars: ${variablesName(op)}` : ''; const keyElements = inputs @@ -55,7 +54,7 @@ function queryBlocks(op: OperationModel, opts: SwrOptions): string[] { : `[${JSON.stringify(op.name)}]`; const key = `export const ${op.name}Key = (${keyParams}) => ${keyElements} as const;`; const keyCall = `${op.name}Key(${inputs ? 'vars' : ''})`; - const useSwr = `useSWR(${keyCall}, () => ${sdkCallText(op, opts.argsStyle, 'vars', true)})`; + const useSwr = `useSWR(${keyCall}, () => ${sdkCallText(op, 'vars', true)})`; // The throw-only `envelope` option is excluded — cached data must stay the plain body. const params = inputs ? `vars: ${variablesName(op)}, init?: Omit` @@ -64,12 +63,12 @@ function queryBlocks(op: OperationModel, opts: SwrOptions): string[] { } /** A mutation op's `use` hook calling `useSWRMutation`. */ -function mutationBlock(op: OperationModel, opts: SwrOptions): string { +function mutationBlock(op: OperationModel): string { // `(_key: string, { arg }: { arg: Variables }) => (…arg)` when the op has // inputs; a no-arg `() => ()` when it has none (`arg` would be unused). const trigger = hasInputs(op) - ? `(_key: string, { arg }: {\n arg: ${variablesName(op)};\n }) => ${sdkCallText(op, opts.argsStyle, 'arg', false)}` - : `() => ${sdkCallText(op, opts.argsStyle, 'arg', false)}`; + ? `(_key: string, { arg }: {\n arg: ${variablesName(op)};\n }) => ${sdkCallText(op, 'arg', false)}` + : `() => ${sdkCallText(op, 'arg', false)}`; const useSwrMutation = `useSWRMutation(${JSON.stringify(op.name)}, ${trigger})`; return hookBlock(op, '', useSwrMutation); } diff --git a/packages/client-generator/src/emitters/tanstack-query.ts b/packages/client-generator/src/emitters/tanstack-query.ts index ee7afdabd9..4e17f48dd8 100644 --- a/packages/client-generator/src/emitters/tanstack-query.ts +++ b/packages/client-generator/src/emitters/tanstack-query.ts @@ -6,8 +6,8 @@ // TanStack's abort `signal`. A paginated query op additionally gets // `InfiniteOptions(vars, init?)` with `initialPageParam`/`getNextPageParam` compiled // from the pagination rule's JSON pointers. Per mutation: `Mutation(init?)`. Calls go -// through the client instance's grouped methods, so the module is independent of the -// sdk's `--args-style`. +// through the client instance's methods, which take one input object in either +// `--args-style`; only the infinite query's cursor override differs between them. // // The factory bodies are authored as source text — the emitted module verbatim // and normalizes everything to the printer's canonical style. Every interpolated piece @@ -35,6 +35,8 @@ export type TanstackOptions = { /** Leading element for every query/mutation key — namespaces the cache when several * generated APIs share one QueryClient (operationIds may collide across APIs). */ queryKeyPrefix?: string; + /** The sdk's call shape — the infinite query overrides the cursor inside it. */ + argsStyle?: 'grouped' | 'flat'; }; /** Render the full TanStack Query module source. `''` when there are no wrappable operations. */ @@ -45,7 +47,7 @@ export function renderTanstackModule(model: ApiModel, opts: TanstackOptions): st const source = [ importHeader(ops, opts, pagination), ...ops.filter(isQuery).map((op) => queryKeySource(op, opts.queryKeyPrefix)), - factoriesSource(model, ops, pagination, opts.queryKeyPrefix), + factoriesSource(model, ops, pagination, opts.queryKeyPrefix, opts.argsStyle), ...defaultBindings(ops, pagination), ].join('\n'); return source; @@ -114,13 +116,14 @@ function factoriesSource( model: ApiModel, ops: OperationModel[], pagination: ModelPagination, - prefix: string | undefined + prefix: string | undefined, + argsStyle: TanstackOptions['argsStyle'] ): string { const members = ops.flatMap((op) => { if (!isQuery(op)) return [mutationMember(op, prefix)]; const paginated = pagination.get(op.name); return paginated !== undefined && paginated.spec.style !== 'link' - ? [optionsMember(op), infiniteMember(model, op, paginated.spec)] + ? [optionsMember(op), infiniteMember(model, op, paginated.spec, argsStyle)] : [optionsMember(op)]; }); return ( @@ -169,15 +172,22 @@ function mutationMember(op: OperationModel, prefix: string | undefined): string function infiniteMember( model: ApiModel, op: OperationModel, - spec: Exclude + spec: Exclude, + argsStyle: TanstackOptions['argsStyle'] ): string { const { params, keyArg } = varsPieces(op); - const override = `{ ...vars, params: { ...vars.params, ${safeIdent(spec.param)}: pageParam } }`; + // The cursor is a query parameter, so it lands in the sdk's own spelling for one: + // inside the `query` layer, or at the top level of a merged call. + const cursor = safeIdent(spec.param); + const override = + argsStyle === 'flat' + ? `{ ...vars, ${cursor}: pageParam }` + : `{ ...vars, query: { ...vars.query, ${cursor}: pageParam } }`; return ( ` ${op.name}InfiniteOptions: (${params}) => infiniteQueryOptions({\n` + ` queryKey: [...${op.name}QueryKey(${keyArg}), "infinite"] as const,\n` + ` queryFn: ({ pageParam, signal }) => instance.${op.name}(${override}, { ...init, signal, envelope: undefined }),\n` + - nextPageSource(model, op, spec) + + nextPageSource(model, op, spec, argsStyle) + ` })` ); } @@ -186,9 +196,12 @@ function infiniteMember( function nextPageSource( model: ApiModel, op: OperationModel, - spec: Exclude + spec: Exclude, + argsStyle: TanstackOptions['argsStyle'] ): string { const advance = paramsAccess(spec.param); + // Where the caller's own starting value lives, in the sdk's spelling for a query param. + const given = argsStyle === 'flat' ? `vars.${advance}` : `vars.query?.${advance}`; if (spec.style === 'cursor') { const stopEarly = spec.hasMore === undefined @@ -205,7 +218,7 @@ function nextPageSource( : ` const next = lastPage${pointerChain(spec.nextCursor)};\n` + ` return ${checks.join(' || ')} ? undefined : next;\n`; return ( - ` initialPageParam: vars.params?.${advance},\n` + + ` initialPageParam: ${given},\n` + ` getNextPageParam: (lastPage) => {\n` + stopEarly + body + @@ -215,7 +228,7 @@ function nextPageSource( const step = spec.style === 'offset' ? 'lastPageParam + count' : 'lastPageParam + 1'; const start = spec.style === 'offset' ? '0' : '1'; return ( - ` initialPageParam: vars.params?.${advance} ?? ${start},\n` + + ` initialPageParam: ${given} ?? ${start},\n` + ` getNextPageParam: (lastPage, _allPages, lastPageParam) => {\n` + ` const count = ${itemsLength(spec.items)};\n` + ` return count === 0 ? undefined : ${step};\n` + diff --git a/packages/client-generator/src/emitters/wrapper-support.ts b/packages/client-generator/src/emitters/wrapper-support.ts index cae2b4e22d..76d5710d02 100644 --- a/packages/client-generator/src/emitters/wrapper-support.ts +++ b/packages/client-generator/src/emitters/wrapper-support.ts @@ -72,24 +72,12 @@ export function variablesName(op: OperationModel): string { * `flat` spreads `.` (URL-template order), then the slots the op * has. `withInit` appends `{ ...init, envelope: undefined }` — a runtime strip, since * the wrappers cache the fetched body and their `Omit`-typed init is type-only. */ -export function sdkCallText( - op: OperationModel, - argsStyle: 'flat' | 'grouped', - source: string, - withInit: boolean -): string { - const sig = operationSignature(op); +export function sdkCallText(op: OperationModel, source: string, withInit: boolean): string { const args: string[] = []; - if (argsStyle === 'grouped') { - if (sig.hasInputs) args.push(source); - else if (withInit) args.push('{}'); - } else { - for (const { ident } of sig.pathParams) args.push(`${source}.${ident}`); - if (sig.hasQuery) args.push(`${source}.params`); - if (sig.hasBody) args.push(`${source}.body`); - if (sig.hasHeaders) args.push(`${source}.headers`); - if (sig.hasCookies) args.push(`${source}.cookies`); - } + // Every style takes ONE input object, so a wrapper forwards its `Variables` verbatim + // and never has to know which style the sdk was generated with. + if (operationSignature(op).hasInputs) args.push(source); + else if (withInit) args.push('{}'); if (withInit) args.push('{ ...init, envelope: undefined }'); return `${op.name}(${args.join(', ')})`; } diff --git a/packages/client-generator/src/generators/cli/index.ts b/packages/client-generator/src/generators/cli/index.ts index 5fa39db834..0410920692 100644 --- a/packages/client-generator/src/generators/cli/index.ts +++ b/packages/client-generator/src/generators/cli/index.ts @@ -33,6 +33,7 @@ export const cliGenerator: Generator = ({ model, outputPath, emit, selected }) = zodSelected: selected?.includes('zod') ?? false, binName: emit.binName ?? commandName(stem), pagination: emit.pagination, + argsStyle: emit.argsStyle ?? 'grouped', }); return [{ path: join(dir, `${stem}.cli.ts`), content }]; }; diff --git a/packages/client-generator/src/generators/swr/index.ts b/packages/client-generator/src/generators/swr/index.ts index 977bb3d938..e6443c6d52 100644 --- a/packages/client-generator/src/generators/swr/index.ts +++ b/packages/client-generator/src/generators/swr/index.ts @@ -20,7 +20,6 @@ import type { Generator } from '../types.js'; export const swrGenerator: Generator = ({ model, outputPath, emit }) => { const { dir, stem } = anchor(outputPath); const content = renderSwrModule(model, { - argsStyle: emit.argsStyle ?? 'flat', sdkModule: `./${stem}.${emit.importExt ?? 'js'}`, }); if (content === '') return []; diff --git a/packages/client-generator/src/generators/tanstack-query/index.ts b/packages/client-generator/src/generators/tanstack-query/index.ts index 973cf35014..9a455da762 100644 --- a/packages/client-generator/src/generators/tanstack-query/index.ts +++ b/packages/client-generator/src/generators/tanstack-query/index.ts @@ -24,6 +24,7 @@ export function tanstackQueryGenerator(framework: 'react' | 'vue' | 'svelte' | ' return ({ model, outputPath, emit }) => { const { dir, stem } = anchor(outputPath); const content = renderTanstackModule(model, { + argsStyle: emit.argsStyle ?? 'grouped', sdkModule: `./${stem}.${emit.importExt ?? 'js'}`, framework, pagination: emit.pagination, diff --git a/packages/client-generator/src/generators/typescript/AGENTS.md b/packages/client-generator/src/generators/typescript/AGENTS.md index 940a09d833..8a5b884cd8 100644 --- a/packages/client-generator/src/generators/typescript/AGENTS.md +++ b/packages/client-generator/src/generators/typescript/AGENTS.md @@ -10,8 +10,8 @@ the copy that ships to users — that asset is generated, so never edit it by ha ## What it emits The typed TypeScript client itself: model types with JSDoc, type guards, the `Ops` -type map, the `OPERATIONS` descriptor table, a `client` instance, flat call sugar, -and either the embedded runtime (`runtime: inline`) or imports from +type map, the `OPERATIONS` descriptor table, a `client` instance, one binding per +operation, and either the embedded runtime (`runtime: inline`) or imports from `@redocly/client-generator` (`runtime: package`). ## Design decisions that must hold @@ -30,14 +30,21 @@ and either the embedded runtime (`runtime: inline`) or imports from description — the only real fix), a name that isn't a valid identifier, or a clash with a name the generated module already declares. A vague "collides or is invalid" message leaves the publisher unable to act. +- **One operation, one function, one input shape.** The module-level names are bindings + of the client's own methods (`export const { getOrder } = client;`), never wrappers, so + `getOrder` and `client.getOrder` cannot disagree about their arguments. `argsStyle` + shapes the method itself: `grouped` (the default) namespaces the inputs by transport + layer — `path`, `query`, `headers`, `cookies`, `body` — and `flat` merges them into one + object, which the runtime converts back using the descriptor's own parameter list. An + operation whose merged names would collide keeps the grouped shape. - **Throw mode returns the body**; `{ envelope: true }` opts into `{ data, headers, response }` with typed declared headers. Result mode returns `{ data, error, response }` and ignores `envelope`. ## Emitters that implement it -`emitters/client-assembly.ts` (orchestration), `render-client.ts` (Ops, aliases, flat -sugar), `descriptor.ts`, `ts-type.ts`/`ts-literal.ts` (type + data text), `sse.ts`, +`emitters/client-assembly.ts` (orchestration), `render-client.ts` (Ops, aliases, input +shapes), `descriptor.ts`, `ts-type.ts`/`ts-literal.ts` (type + data text), `sse.ts`, `pagination.ts`, `response-headers.ts`, `inline-runtime.ts`, `setup-bake.ts`. ## Ejecting it diff --git a/packages/client-generator/src/generators/typescript/index.ts b/packages/client-generator/src/generators/typescript/index.ts index 6be1097535..e690ee9ac6 100644 --- a/packages/client-generator/src/generators/typescript/index.ts +++ b/packages/client-generator/src/generators/typescript/index.ts @@ -61,24 +61,17 @@ export function typescriptSample(op: OperationModel, ctx: SampleContext): CodeSa const stem = ctx.outputPath.replace(/^.*[\\/]/, '').replace(/\.[^.]+$/, ''); const specifier = `./${stem}.${ctx.emit.importExt ?? 'js'}`; const requiredQuery = op.queryParams.filter((param) => param.required); - const slots: string[] = []; - if (requiredQuery.length > 0) { - slots.push( - `params: { ${requiredQuery.map((param) => `'${param.name}': /* … */`).join(', ')} }` - ); - } - if (op.requestBody) slots.push('body: { /* … */ }'); - const args = - ctx.emit.argsStyle === 'grouped' - ? op.pathParams.length + slots.length > 0 - ? [ - `{ ${[...op.pathParams.map((param) => `'${param.name}': '<${param.name}>'`), ...slots].join(', ')} }`, - ] - : [] - : [ - ...op.pathParams.map((param) => `'<${param.name}>'`), - ...(slots.length > 0 ? [`{ ${slots.join(', ')} }`] : []), - ]; + const merged = ctx.emit.argsStyle === 'flat'; + const path = op.pathParams.map((param) => `${param.name}: '<${param.name}>'`); + const query = requiredQuery.map((param) => `${param.name}: /* … */`); + const parts = merged + ? [...path, ...query, ...(op.requestBody ? ['/* body properties */'] : [])] + : [ + ...(path.length > 0 ? [`path: { ${path.join(', ')} }`] : []), + ...(query.length > 0 ? [`query: { ${query.join(', ')} }`] : []), + ...(op.requestBody ? ['body: { /* … */ }'] : []), + ]; + const args = parts.length > 0 ? [`{ ${parts.join(', ')} }`] : []; return { lang: 'typescript', label: 'TypeScript SDK', diff --git a/packages/client-generator/src/intermediate-representation/__tests__/sanitize-identifiers.test.ts b/packages/client-generator/src/intermediate-representation/__tests__/sanitize-identifiers.test.ts index b8d6c14c3c..0da20105b0 100644 --- a/packages/client-generator/src/intermediate-representation/__tests__/sanitize-identifiers.test.ts +++ b/packages/client-generator/src/intermediate-representation/__tests__/sanitize-identifiers.test.ts @@ -1,11 +1,7 @@ import { logger } from '@redocly/openapi-core'; import type { ApiModel, OperationModel, SchemaModel } from '../model.js'; -import { - assertPathParamsAvoidArgSlots, - assertSafeIdentifiers, - sanitizeIdentifiers, -} from '../sanitize-identifiers.js'; +import { assertSafeIdentifiers, sanitizeIdentifiers } from '../sanitize-identifiers.js'; function model(schemas: ApiModel['schemas'], operations: OperationModel[] = []): ApiModel { return { @@ -307,31 +303,6 @@ describe('sanitizeIdentifiers', () => { }); }); -describe('assertPathParamsAvoidArgSlots', () => { - function opWithPathParam(name: string): OperationModel { - return op({ - path: `/x/{${name}}`, - pathParams: [ - { name, in: 'path', required: true, schema: { kind: 'scalar', scalar: 'string' } }, - ], - }); - } - - it('throws for a path parameter named after a request-args slot', () => { - // The runtime routes path values as `args[param.name]` at the top level, next to the - // `params`/`body`/`headers`/`cookies` slots — a same-named path param is ambiguous. - const m = model([], [opWithPathParam('body')]); - expect(() => assertPathParamsAvoidArgSlots(m)).toThrow( - /path parameter "body".*rename the parameter/s - ); - }); - - it('allows a path parameter named init (only a flat-sugar binding, remapped there)', () => { - const m = model([], [opWithPathParam('init')]); - expect(() => assertPathParamsAvoidArgSlots(m)).not.toThrow(); - }); -}); - describe('assertSafeIdentifiers', () => { it('passes for a fully sanitized model', () => { const m = model( diff --git a/packages/client-generator/src/intermediate-representation/build.ts b/packages/client-generator/src/intermediate-representation/build.ts index 330f84b5e1..bc45b2a5dd 100644 --- a/packages/client-generator/src/intermediate-representation/build.ts +++ b/packages/client-generator/src/intermediate-representation/build.ts @@ -35,11 +35,7 @@ import type { SecuritySchemeModel, ServiceModel, } from './model.js'; -import { - assertPathParamsAvoidArgSlots, - assertSafeIdentifiers, - sanitizeIdentifiers, -} from './sanitize-identifiers.js'; +import { assertSafeIdentifiers, sanitizeIdentifiers } from './sanitize-identifiers.js'; type Oas3SecurityScheme = { type?: string; @@ -253,7 +249,6 @@ export function buildApiModel(doc: Oas3Definition): ApiModel { // Hard gate: no unsafe name may reach the printer (see sanitize-identifiers.ts). assertSafeIdentifiers(model); // A path parameter named like a request-args slot cannot be routed — fail loudly. - assertPathParamsAvoidArgSlots(model); return model; } diff --git a/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts b/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts index 2b07decae7..d88e752d4b 100644 --- a/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts +++ b/packages/client-generator/src/intermediate-representation/sanitize-identifiers.ts @@ -3,7 +3,6 @@ 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 { NotSupportedError } from '../errors.js'; import type { ApiModel, OperationModel, SchemaModel } from './model.js'; /** @@ -114,31 +113,6 @@ function uniquePascalIdent(name: string, used: Set, usedPascals: Set { const context = sources(); const code = await runCli(context.list, ['syncer', 'orders', 'getOrder', 'ord_9']); expect(code).toBe(0); - expect(context.syncer.calls).toEqual([{ name: 'getOrder', variables: { orderId: 'ord_9' } }]); + expect(context.syncer.calls).toEqual([ + { name: 'getOrder', variables: { path: { orderId: 'ord_9' } } }, + ]); expect(context.main.calls).toEqual([]); }); @@ -462,19 +464,19 @@ describe('credential flags follow the declared schemes', () => { }); describe('runCli', () => { - it('dispatches grouped args and pretty-prints the JSON result', async () => { + it('dispatches inputs grouped by layer and pretty-prints the JSON result', async () => { const { wiring, calls, out } = fakeWiring({ results: { getOrder: { id: 'ord_1' } } }); const code = await runCli(COMMANDS, wiring, ['orders', 'getOrder', 'ord_1']); expect(code).toBe(0); - expect(calls).toEqual([{ name: 'getOrder', variables: { orderId: 'ord_1' } }]); + expect(calls).toEqual([{ name: 'getOrder', variables: { path: { orderId: 'ord_1' } } }]); expect(JSON.parse(out.join('\n'))).toEqual({ id: 'ord_1' }); }); - it('passes query params under `params` and prints nothing for void results', async () => { + it('passes query params under `query` and prints nothing for void results', async () => { const { wiring, calls, out } = fakeWiring(); const code = await runCli(COMMANDS, wiring, ['orders', 'listOrders', '--status', 'open']); expect(code).toBe(0); - expect(calls[0]).toEqual({ name: 'listOrders', variables: { params: { status: 'open' } } }); + expect(calls[0]).toEqual({ name: 'listOrders', variables: { query: { status: 'open' } } }); expect(out).toEqual([]); }); diff --git a/packages/client-generator/src/runtime/__tests__/create-client.test.ts b/packages/client-generator/src/runtime/__tests__/create-client.test.ts index ff663c8683..b68c258731 100644 --- a/packages/client-generator/src/runtime/__tests__/create-client.test.ts +++ b/packages/client-generator/src/runtime/__tests__/create-client.test.ts @@ -77,17 +77,21 @@ const OPS = { interface Ops { getOrder: { - args: { orderId: string; params?: { expand?: string }; headers?: Record }; + args: { + path: { orderId: string }; + query?: { expand?: string }; + headers?: Record; + }; result: { id: string }; }; createPet: { args: { body: { name: string } }; result: { id: string } }; - listRaw: { args: { params?: { filter?: string[] } }; result: string }; - search: { args: { params?: { ids?: string[]; path?: string } }; result: string }; + listRaw: { args: { query?: { filter?: string[] } }; result: string }; + search: { args: { query?: { ids?: string[]; path?: string } }; result: string }; secured: { args: Record; result: string }; stream: { args: { body?: { topic: string } }; result: { n: number }; kind: 'sse' }; streamPlain: { args: Record; result: string; kind: 'sse' }; listOrders: { - args: { params?: { cursor?: string; limit?: number } }; + args: { query?: { cursor?: string; limit?: number } }; result: { orders: Array<{ id: string }>; nextCursor?: string }; item: { id: string }; }; @@ -146,7 +150,7 @@ describe('createClientCore', () => { ]); const client = createClientCore<{ listRepos: { - args: { params?: { per_page?: number } }; + args: { query?: { per_page?: number } }; result: string[]; item: string; }; @@ -157,7 +161,7 @@ describe('createClientCore', () => { { paginate: { pages: paginatePages, items: paginateItems, pagesByLink, itemsByLink } } ); const seen: string[] = []; - for await (const repo of client.listRepos.items({ params: { per_page: 1 } })) seen.push(repo); + for await (const repo of client.listRepos.items({ query: { per_page: 1 } })) seen.push(repo); expect(seen).toEqual(['a', 'b']); expect(calls[0].url).toBe('https://x/repos?per_page=1'); // Page 2 rides the Link target's query params through the same declared endpoint. @@ -173,7 +177,7 @@ describe('createClientCore', () => { jsonOk(['x']), ]); const client = createClientCore(OPS, { serverUrl: 'https://x', fetch: fetchImpl }); - await client.getOrder({ orderId: 'o1' }); + await client.getOrder({ path: { orderId: 'o1' } }); expect((calls[0].init.headers as Record).Accept).toBe('application/json'); await client.listRaw({}); expect((calls[1].init.headers as Record).Accept).toBe('text/*'); @@ -184,8 +188,8 @@ describe('createClientCore', () => { it('rejects an unknown top-level argument key (flat-style shape passed to a grouped call)', async () => { const client = createClientCore(OPS, { serverUrl: 'https://x' }); - await expect(client.getOrder({ orderId: 'o1', limit: 10 } as never)).rejects.toThrow( - /Unknown argument "limit" for operation "getOrder".*params/ + await expect(client.getOrder({ path: { orderId: 'o1' }, limit: 10 } as never)).rejects.toThrow( + /Unknown argument "limit" for operation "getOrder".*grouped by layer/ ); }); @@ -196,8 +200,8 @@ describe('createClientCore', () => { expect( await getOrder({ - orderId: 'a/b', - params: { expand: 'items' }, + path: { orderId: 'a/b' }, + query: { expand: 'items' }, headers: { 'X-Trace': 7, 'X-Skip': null }, }) ).toEqual({ id: 'o1' }); @@ -243,7 +247,7 @@ describe('createClientCore', () => { ]); const client = createClientCore(OPS, { serverUrl: 'https://x', fetch: fetchImpl }); - expect(await client.listRaw({ params: { filter: ['a', 'b'] } })).toBe('plain'); + expect(await client.listRaw({ query: { filter: ['a', 'b'] } })).toBe('plain'); expect(calls[0].url).toBe('https://x/raw?filter=a|b'); // parseAs overrides the descriptor's kind at runtime. @@ -253,7 +257,7 @@ describe('createClientCore', () => { it('resolves OpenAPI style defaults: explode:false alone comma-joins, allowReserved alone skips encoding', async () => { const { calls, fetchImpl } = spy([jsonOk('s')]); const client = createClientCore(OPS, { serverUrl: 'https://x', fetch: fetchImpl }); - await client.search({ params: { ids: ['a', 'b'], path: 'a/b' } }); + await client.search({ query: { ids: ['a', 'b'], path: 'a/b' } }); expect(calls[0].url).toBe('https://x/search?ids=a,b&path=a/b'); }); @@ -269,7 +273,7 @@ describe('createClientCore', () => { { onRequest: () => {} }, // no onError — skipped by the error chain { onError: (e) => new Error(`wrapped:${(e as { status: number }).status}`) } ); - await expect(client.getOrder({ orderId: '1' })).rejects.toThrow('wrapped:500'); + await expect(client.getOrder({ path: { orderId: '1' } })).rejects.toThrow('wrapped:500'); }); it('result mode: non-ok returns { error }, ok returns { data } — without throwing', async () => { @@ -285,13 +289,15 @@ describe('createClientCore', () => { serverUrl: 'https://x', errorMode: 'result', }); - const bad = (await client.getOrder({ orderId: '1' })) as unknown as { + const bad = (await client.getOrder({ path: { orderId: '1' } })) as unknown as { error: { title: string }; response: Response; }; expect(bad.error).toEqual({ title: 'x' }); expect(bad.response.status).toBe(500); - const good = (await client.getOrder({ orderId: '1' })) as unknown as { data: { id: string } }; + const good = (await client.getOrder({ path: { orderId: '1' } })) as unknown as { + data: { id: string }; + }; expect(good.data).toEqual({ id: 'ok' }); }); @@ -319,7 +325,7 @@ describe('createClientCore', () => { expect(calls[1].url).toContain('sig=v'); // Ops without security skip resolveAuth entirely. - await client.getOrder({ orderId: '1' }); + await client.getOrder({ path: { orderId: '1' } }); expect((calls[2].init.headers as Record).Authorization).toBeUndefined(); }); @@ -377,7 +383,7 @@ describe('createClientCore', () => { // Caller overrides the explicit header-param slot too. await client.getOrder( - { orderId: '1', headers: { 'X-Trace': 'from-args' } }, + { path: { orderId: '1' }, headers: { 'X-Trace': 'from-args' } }, { headers: { 'X-Trace': 'from-caller' } } ); expect((calls[1].init.headers as Record)['X-Trace']).toBe('from-caller'); @@ -392,7 +398,9 @@ describe('createClientCore', () => { ]); const client = createClientCore(OPS, { fetch: fetchImpl, serverUrl: 'https://x' }); client.configure({ errorMode: 'result' }); - await expect(client.getOrder({ orderId: '1' })).rejects.toMatchObject({ status: 500 }); + await expect(client.getOrder({ path: { orderId: '1' } })).rejects.toMatchObject({ + status: 500, + }); }); it('sse ops dispatch to the sse capability (with prepared url + body); absent capability throws sync', async () => { @@ -460,7 +468,7 @@ describe('createClientCore', () => { expect((client.getOrder as unknown as Record).pages).toBeUndefined(); expect((client.getOrder as unknown as Record).items).toBeUndefined(); - const args = { params: { limit: 2 } }; + const args = { query: { limit: 2 } }; const init = { headers: { 'X-Trace': '1' } }; const yielded = []; for await (const page of client.listOrders.pages(args, init)) yielded.push(page); @@ -469,7 +477,9 @@ describe('createClientCore', () => { expect(seen[0]).toEqual(['pages', OPS.listOrders.pagination, args, init]); for await (const item of client.listOrders.items()) expect(item).toEqual({ id: 'o9' }); - expect(seen[1]).toEqual(['items', 'cursor', undefined, undefined]); // bare call: no args/init + // The iterators normalize their inputs before handing them over, so an argument-less + // call reaches the capability as an empty input rather than `undefined`. + expect(seen[1]).toEqual(['items', 'cursor', {}, undefined]); // bare call: no args/init }); it('result mode: .pages/.items iterate RAW pages (the envelope is unwrapped before the pointers)', async () => { @@ -586,7 +596,7 @@ describe('createClientCore', () => { // The merged serverUrl is actually used. const { calls, fetchImpl } = spy([jsonOk({ id: '1' })]); client.configure({ fetch: fetchImpl }); - await client.getOrder({ orderId: '1' }); + await client.getOrder({ path: { orderId: '1' } }); expect(calls[0].url).toBe('https://later/orders/1'); }); @@ -601,7 +611,7 @@ describe('createClientCore', () => { const { calls, fetchImpl } = spy([jsonOk({ id: '1' })]); const client = createClientCore(OPS); client.configure({ fetch: fetchImpl }); - await client.getOrder({ orderId: '1' }); + await client.getOrder({ path: { orderId: '1' } }); expect(calls[0].url).toBe('/orders/1'); }); diff --git a/packages/client-generator/src/runtime/__tests__/paginate.test.ts b/packages/client-generator/src/runtime/__tests__/paginate.test.ts index f4a5346afb..08202399f3 100644 --- a/packages/client-generator/src/runtime/__tests__/paginate.test.ts +++ b/packages/client-generator/src/runtime/__tests__/paginate.test.ts @@ -18,7 +18,7 @@ function stub(data: unknown[]) { return data[calls.length - 1]; }; const sentParams = (name: string) => - calls.map((c) => (c.args?.params as Record | undefined)?.[name]); + calls.map((c) => (c.args?.query as Record | undefined)?.[name]); return { calls, call, sentParams }; } @@ -105,11 +105,11 @@ describe('pages — cursor style', () => { expect(calls).toHaveLength(2); }); - it('resumes from a caller-provided cursor, preserving other params', async () => { + it('resumes from a caller-provided cursor, preserving the other query values', async () => { const data = [{ orders: [{ id: 'o3' }] }]; const { call, calls } = stub(data); - await collect(pages(call, CURSOR, { params: { cursor: 'c2', limit: 5 } })); - expect(calls[0].args?.params).toEqual({ cursor: 'c2', limit: 5 }); + await collect(pages(call, CURSOR, { query: { cursor: 'c2', limit: 5 } })); + expect(calls[0].args?.query).toEqual({ cursor: 'c2', limit: 5 }); }); it('advances through numeric cursors end-to-end', async () => { @@ -144,15 +144,15 @@ describe('pages — cursor style', () => { ); }); - it('never mutates the caller args; each request gets a fresh params clone', async () => { + it('never mutates the caller args; each request gets a fresh query bag', async () => { const data = [{ orders: [{ id: 'o1' }], nextCursor: 'c2' }, { orders: [] }]; - const args = { params: { limit: 2 }, headers: { 'X-Trace': '1' } }; + const args = { query: { limit: 2 }, headers: { 'X-Trace': '1' } }; const snapshot = structuredClone(args); const { call, calls } = stub(data); await collect(pages(call, CURSOR, args)); expect(args).toEqual(snapshot); - expect(calls[0].args?.params).not.toBe(args.params); - expect(calls[1].args?.params).not.toBe(calls[0].args?.params); + expect(calls[0].args?.query).not.toBe(args.query); + expect(calls[1].args?.query).not.toBe(calls[0].args?.query); }); it('forwards the same init (incl. AbortSignal) to every call', async () => { @@ -189,7 +189,7 @@ describe('pages — offset style', () => { it('starts at the caller offset when provided', async () => { const data = [{ orders: ['k'] }, { orders: [] }]; const { call, sentParams } = stub(data); - await collect(pages(call, OFFSET, { params: { offset: 10 } })); + await collect(pages(call, OFFSET, { query: { offset: 10 } })); expect(sentParams('offset')).toEqual([10, 11]); }); @@ -198,7 +198,7 @@ describe('pages — offset style', () => { // omits the param for those values, so the iterator must not start at page 0. const data = [{ orders: ['k'] }, { orders: [] }]; const { call, sentParams } = stub(data); - await collect(pages(call, PAGE, { params: { page: null } })); + await collect(pages(call, PAGE, { query: { page: null } })); expect(sentParams('page')).toEqual([1, 2]); }); @@ -206,14 +206,14 @@ describe('pages — offset style', () => { const data = [{ orders: ['k', 'm'] }, { orders: [] }]; const { call, sentParams } = stub(data); // A string offset (common from URL/form input): `'10' + 2` would be `'102'` without coercion. - await collect(pages(call, OFFSET, { params: { offset: '10' } })); + await collect(pages(call, OFFSET, { query: { offset: '10' } })); expect(sentParams('offset')).toEqual([10, 12]); }); it('falls back to the default start when the offset param is not a number', async () => { const data = [{ orders: ['k'] }, { orders: [] }]; const { call, sentParams } = stub(data); - await collect(pages(call, OFFSET, { params: { offset: 'not-a-number' } })); + await collect(pages(call, OFFSET, { query: { offset: 'not-a-number' } })); expect(sentParams('offset')).toEqual([0, 1]); }); @@ -236,7 +236,7 @@ describe('pages — page style', () => { it('starts at the caller page number when provided', async () => { const data = [{ orders: ['x'] }, { orders: [] }]; const { call, sentParams } = stub(data); - await collect(pages(call, PAGE, { params: { page: 5 } })); + await collect(pages(call, PAGE, { query: { page: 5 } })); expect(sentParams('page')).toEqual([5, 6]); }); }); @@ -273,7 +273,7 @@ describe('items', () => { const data = [{ orders: ['a'] }, { orders: [] }]; const init: RequestOptions = { headers: { 'X-Trace': '1' } }; const { call, calls, sentParams } = stub(data); - await collect(items(call, PAGE, { params: { limit: 1 } }, init)); + await collect(items(call, PAGE, { query: { limit: 1 } }, init)); expect(sentParams('limit')).toEqual([1, 1]); for (const c of calls) expect(c.init).toBe(init); }); @@ -311,15 +311,15 @@ describe('pagesByLink / itemsByLink (link style)', () => { return { call, calls }; } - it('follows rel="next" by merging its query params into the next call, then stops', async () => { + it('follows rel="next" by merging its query query into the next call, then stops', async () => { const { call, calls } = linkStub([ { page: ['a'], linkHeader: '; rel="next"' }, { page: ['b'], linkHeader: null }, ]); - const seen = await collect(pagesByLink(call, { params: { per_page: 5 } })); + const seen = await collect(pagesByLink(call, { query: { per_page: 5 } })); expect(seen).toEqual([['a'], ['b']]); - expect(calls[0].args?.params).toEqual({ per_page: 5 }); - expect(calls[1].args?.params).toEqual({ per_page: '5', page: '2' }); + expect(calls[0].args?.query).toEqual({ per_page: 5 }); + expect(calls[1].args?.query).toEqual({ per_page: '5', page: '2' }); }); it('keeps every value of a repeated query param in the next target', async () => { @@ -328,7 +328,7 @@ describe('pagesByLink / itemsByLink (link style)', () => { { page: ['b'], linkHeader: null }, ]); await collect(pagesByLink(call, {})); - expect(calls[1].args?.params).toEqual({ tag: ['dogs', 'cats'], page: '2' }); + expect(calls[1].args?.query).toEqual({ tag: ['dogs', 'cats'], page: '2' }); }); it('resolves a relative next target against the page URL', async () => { @@ -337,7 +337,7 @@ describe('pagesByLink / itemsByLink (link style)', () => { { page: [2], linkHeader: null }, ]); await collect(pagesByLink(call)); - expect(calls[1].args?.params).toEqual({ cursor: 'abc' }); + expect(calls[1].args?.query).toEqual({ cursor: 'abc' }); }); it('follows links when the page URL itself is relative (relative serverUrl, mocked fetch)', async () => { @@ -354,7 +354,7 @@ describe('pagesByLink / itemsByLink (link style)', () => { }; const seen = await collect(pagesByLink(call)); expect(seen).toEqual([['a'], ['b']]); - expect(calls[1].args?.params).toEqual({ page: '2' }); + expect(calls[1].args?.query).toEqual({ page: '2' }); }); it('throws when the next target repeats (infinite-loop guard)', async () => { diff --git a/packages/client-generator/src/runtime/cli.ts b/packages/client-generator/src/runtime/cli.ts index 08fac9a2fd..26f753f521 100644 --- a/packages/client-generator/src/runtime/cli.ts +++ b/packages/client-generator/src/runtime/cli.ts @@ -32,8 +32,12 @@ export type CliCommand = { description?: string; }>; flags: CliFlag[]; - /** Present when the operation takes a JSON request body. */ - body?: { required: boolean }; + /** + * 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 @@ -54,8 +58,10 @@ export type CliWiring = { /** Credential variable prefix. Defaults to `binName`, constant-cased — set it when the * displayed name and the credential family must differ (a composed multi-API binary). */ envPrefix?: string; - /** The generated instance client (grouped-args methods). */ + /** 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[]; @@ -178,6 +184,32 @@ function oneLine(text: string): string { return text.replace(/\s+/g, ' ').trim(); } +/** + * The parsed argv as one call input, in the style the wired client takes: grouped by layer + * (the default) or merged into one object. + */ +function callInputs( + command: CliCommand, + positionals: Record, + params: Record, + body: unknown, + argsStyle: CliWiring['argsStyle'] +): Record | undefined { + const inputs: Record = {}; + if (argsStyle === 'flat') { + Object.assign(inputs, positionals, params); + if (body !== undefined) { + if (command.body?.merged === true) Object.assign(inputs, body as Record); + else inputs.body = body; + } + } else { + if (Object.keys(positionals).length > 0) inputs.path = positionals; + if (Object.keys(params).length > 0) inputs.query = params; + if (body !== undefined) inputs.body = body; + } + return Object.keys(inputs).length > 0 ? inputs : undefined; +} + /** Resolve argv against the command table. Pure — no I/O, no env. */ export function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation { if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' }; @@ -699,10 +731,7 @@ async function runSingle( }); } - const variables: Record = { ...positionals }; - if (Object.keys(params).length > 0) variables.params = params; - if (body !== undefined) variables.body = body; - const argument = Object.keys(variables).length > 0 ? variables : undefined; + const argument = callInputs(command, positionals, params, body, wiring.argsStyle); // The client's methods are typed per-operation; the dispatcher only needs "callable // by name", so one localized widening here keeps the emitted wiring cast-free. diff --git a/packages/client-generator/src/runtime/create-client.ts b/packages/client-generator/src/runtime/create-client.ts index d63205a45f..d4316fcb87 100644 --- a/packages/client-generator/src/runtime/create-client.ts +++ b/packages/client-generator/src/runtime/create-client.ts @@ -74,14 +74,66 @@ export type Capabilities = SendCapabilities & { }; }; -/** The grouped args wire shape: path params by name plus the `params`/`body`/`headers`/`cookies` slots. */ +/** + * One call's inputs, namespaced by transport layer. `argsStyle: 'flat'` clients accept the + * merged form instead (every parameter and body property at one level) — `namespaceArgs` + * converts it to this shape before anything downstream reads it. + */ export type OperationArgs = { - params?: Record; + path?: Record; + query?: Record; body?: unknown; headers?: Record; cookies?: Record; } & Record; +/** The five layer keys, and the only top-level keys a namespaced call may carry. */ +const LAYERS: readonly string[] = ['path', 'query', 'body', 'headers', 'cookies']; + +/** Where a declared parameter's `in` value puts it. */ +const LAYER_OF: Record = { + path: 'path', + query: 'query', + header: 'headers', + cookie: 'cookies', +}; + +/** + * Merged (`argsStyle: 'flat'`) args → the namespaced shape. A key that names a declared + * parameter goes to that parameter's layer; anything else is a property of the request + * body, which is how a flat call spells an object body. `body` stays reserved for the + * operations a flat call cannot merge (an array, a scalar, or a binary body). + */ +function namespaceArgs(op: OperationDescriptor, args: OperationArgs): OperationArgs { + const layers: Record> = {}; + let body: unknown; + let properties: Record | undefined; + const layerOfParam = new Map((op.params ?? []).map((param) => [param.name, param.in])); + for (const [key, value] of Object.entries(args)) { + const layer = LAYER_OF[layerOfParam.get(key) ?? '']; + if (layer !== undefined) { + (layers[layer] ??= {})[key] = value; + } else if (key === 'body' && op.body !== undefined) { + body = value; + } else if (op.body !== undefined) { + (properties ??= {})[key] = value; + } else { + throw new TypeError( + `Unknown argument "${key}" for operation "${op.id}": it names no declared parameter, and the operation takes no request body.` + ); + } + } + const namespaced: OperationArgs = {}; + if (layers.path) namespaced.path = layers.path; + // The flat surface types every query value, so the collected bag is one by construction. + if (layers.query) namespaced.query = layers.query as Record; + if (layers.headers) namespaced.headers = layers.headers; + if (layers.cookies) namespaced.cookies = layers.cookies; + if (properties !== undefined) namespaced.body = properties; + else if (body !== undefined) namespaced.body = body; + return namespaced; +} + /** The response reader implied by the descriptor (before any per-call `parseAs` override). */ /** * The `Accept` header matching how the response will be read — a blob/text operation @@ -104,31 +156,30 @@ function kindFor(op: OperationDescriptor): ParseAs | 'void' { return 'auto'; } -/** Route the grouped args by the descriptor: path values, query object, body, extra headers, cookies. */ +/** The call's inputs in namespaced form, converting first on a flat-style client. */ +function inputOf( + op: OperationDescriptor, + args: OperationArgs, + config: ClientConfig +): OperationArgs { + return config.argsStyle === 'flat' ? namespaceArgs(op, args) : args; +} + +/** Route the namespaced args to the request pieces. */ function splitArgs(op: OperationDescriptor, args: OperationArgs) { - const path: Record = {}; - const pathNames = new Set(); - for (const param of op.params ?? []) { - if (param.in === 'path') { - pathNames.add(param.name); - path[param.name] = args[param.name]; - } - } - // An unknown top-level key can only be a bug (usually a flat-style call shape passed - // to a grouped client: `{ limit: 10 }` instead of `{ params: { limit: 10 } }`). - // TypeScript catches it, but transpilers that skip type-checking would otherwise - // ship a request that silently drops the value — fail the call loudly instead. + // An unknown layer key can only be a bug (usually flat-style args on a namespaced + // client). TypeScript catches it, but a transpiler that skips type-checking would + // otherwise ship a request that silently drops the value — fail the call loudly. for (const key of Object.keys(args)) { - if (key === 'params' || key === 'body' || key === 'headers' || key === 'cookies') continue; - if (pathNames.has(key)) continue; - throw new TypeError( - `Unknown argument "${key}" for operation "${op.id}". Query parameters go under params: { … } and the request body under body; valid keys are params, body, headers, cookies` + - (pathNames.size > 0 ? `, and the path parameters (${[...pathNames].join(', ')}).` : '.') - ); + if (!LAYERS.includes(key)) { + throw new TypeError( + `Unknown argument "${key}" for operation "${op.id}". Inputs are grouped by layer: ${LAYERS.join(', ')}.` + ); + } } return { - path, - query: args.params, + path: args.path ?? {}, + query: args.query, body: args.body, headers: args.headers, cookies: args.cookies, @@ -405,7 +456,8 @@ export function createClientCore< for (const [name, op] of Object.entries(operations)) { if (op.responseKind === 'sse') { - const method = (args: OperationArgs = {}, init: SseOptions = {}) => { + const method = (given: OperationArgs = {}, init: SseOptions = {}) => { + const args = inputOf(op, given, config); if (!caps.sse) { throw new Error(`SSE capability not wired: cannot stream operation "${op.id}"`); } @@ -429,8 +481,13 @@ export function createClientCore< Object.defineProperty(method, 'operationId', { value: op.id }); client[name] = method; } else { - const method = (args: OperationArgs = {}, init: RequestOptions = {}) => + // `raw` takes namespaced args; `method` is the public entry that accepts whichever + // style the client was generated with. The iterators namespace once and then drive + // `raw`, so a flat call is never converted twice. + const raw = (args: OperationArgs = {}, init: RequestOptions = {}) => execute(config, op, args, init, caps); + const method = (args: OperationArgs = {}, init: RequestOptions = {}) => + raw(inputOf(op, args, config), init); Object.defineProperty(method, 'name', { value: name }); Object.defineProperty(method, 'operationId', { value: op.id }); const spec = op.pagination; @@ -449,31 +506,42 @@ export function createClientCore< pages: (args?: OperationArgs, init?: RequestOptions) => paginateCapability(caps, op).pagesByLink( linkPageCall(config, op, caps), - args, + inputOf(op, args ?? {}, config), init ), items: (args?: OperationArgs, init?: RequestOptions) => paginateCapability(caps, op).itemsByLink( linkPageCall(config, op, caps), spec, - args, + inputOf(op, args ?? {}, config), init ), }) : Object.assign(method, { pages: (args?: OperationArgs, init?: RequestOptions) => - paginateCapability(caps, op).pages(pageCall(method, config), spec, args, init), + paginateCapability(caps, op).pages( + pageCall(raw, config), + spec, + inputOf(op, args ?? {}, config), + init + ), items: (args?: OperationArgs, init?: RequestOptions) => - paginateCapability(caps, op).items(pageCall(method, config), spec, args, init), + paginateCapability(caps, op).items( + pageCall(raw, config), + spec, + inputOf(op, args ?? {}, config), + init + ), }); } } // Core members are assigned AFTER the operation loop — they win over colliding op names. client.configure = (next: ClientConfig): void => { - // `errorMode` is fixed at generate time (it shapes the static types); flipping it at - // runtime would silently desync return shapes from `Client`, so it is ignored. - const { errorMode: _fixed, auth, ...rest } = next; + // `errorMode` and `argsStyle` are fixed at generate time (they shape the static types); + // flipping either at runtime would silently desync the calls from `Client`, so both + // are ignored here. + const { errorMode: _fixedMode, argsStyle: _fixedStyle, auth, ...rest } = next; Object.assign(config, rest); // `auth` merges into existing credentials (like the `auth.*` setters) rather than // replacing wholesale — so `configure({ auth: { bearer } })` keeps a previously set diff --git a/packages/client-generator/src/runtime/paginate.ts b/packages/client-generator/src/runtime/paginate.ts index 186a446a96..f794ad2986 100644 --- a/packages/client-generator/src/runtime/paginate.ts +++ b/packages/client-generator/src/runtime/paginate.ts @@ -5,7 +5,7 @@ import type { PaginationSpec, QueryValue, RequestOptions } from './types.js'; * Auto-pagination (capability module — wired into `createClient`, dispatched by the * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's * `param` query parameter, per its `style`. The caller's args are never mutated — each - * request gets a fresh `params` clone — and `init` is forwarded to every call. + * request gets a fresh `query` clone — and `init` is forwarded to every call. * * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a * result-mode client the attachment unwraps the envelope first), so a failed page @@ -39,7 +39,7 @@ export function resolvePointer(value: unknown, pointer: string): unknown { /** * Iterate an operation's full page results. Every page is yielded before the stop * condition is evaluated, so the last page always arrives. Cursor style resumes from a - * caller-provided `params[spec.param]`, stops when the optional `hasMore` pointer + * caller-provided `query[spec.param]`, stops when the optional `hasMore` pointer * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and * throws if the next cursor is not a string or number, or * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page @@ -53,11 +53,11 @@ export async function* pages( init?: RequestOptions ): AsyncGenerator { if (spec.style === 'cursor') { - let cursor: unknown = args.params?.[spec.param]; + let cursor: unknown = args.query?.[spec.param]; while (true) { - const params = { ...args.params }; - if (cursor !== undefined) params[spec.param] = cursor as QueryValue; - const page = await call({ ...args, params }, init); + const query = { ...args.query }; + if (cursor !== undefined) query[spec.param] = cursor as QueryValue; + const page = await call({ ...args, query }, init); yield page; // Connection-style APIs keep a non-null cursor on the last page and signal the // end via a boolean flag — honor it before the cursor check to skip the @@ -80,20 +80,17 @@ export async function* pages( // cannot carry — the client wires those operations to `pagesByLink` instead. throw new Error('link-style pagination iterates via pagesByLink'); } else { - // Coerce the starting position to a number: a caller may pass `params[spec.param]` as a + // Coerce the starting position to a number: a caller may pass `query[spec.param]` as a // string (common from URL/form input), and `+=` on a string would concatenate. `null` // and `''` count as absent — `Number` would turn them into 0, but a one-shot call // omits the param for those values, so the iterator must not start at position 0. - const start = args.params?.[spec.param]; + const start = args.query?.[spec.param]; const fallback = spec.style === 'page' ? 1 : 0; const absent = start === undefined || start === null || start === ''; let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start); let previousItems: string | undefined; while (true) { - const page = await call( - { ...args, params: { ...args.params, [spec.param]: position } }, - init - ); + const page = await call({ ...args, query: { ...args.query, [spec.param]: position } }, init); const pageItems = resolvePointer(page, spec.items); // Some APIs clamp a past-the-end offset/page to the last non-empty page instead // of returning an empty one — the repeated page would otherwise loop forever @@ -164,10 +161,10 @@ export async function* pagesByLink( args: OperationArgs = {}, init?: RequestOptions ): AsyncGenerator { - let params = args.params; + let query = args.query; let previous: string | undefined; while (true) { - const { page, linkHeader, url } = await call({ ...args, params }, init); + const { page, linkHeader, url } = await call({ ...args, query }, init); yield page as TPage; const target = linkNext(linkHeader); if (target === undefined) return; @@ -190,7 +187,7 @@ export async function* pagesByLink( else if (Array.isArray(seen)) seen.push(value); else linkParams[key] = [seen, value]; } - params = { ...args.params, ...linkParams }; + query = { ...args.query, ...linkParams }; } } diff --git a/packages/client-generator/src/runtime/types.ts b/packages/client-generator/src/runtime/types.ts index 03fd5febee..85fdfdc157 100644 --- a/packages/client-generator/src/runtime/types.ts +++ b/packages/client-generator/src/runtime/types.ts @@ -199,6 +199,12 @@ export type ClientConfig = { auth?: AuthCredentials; /** Fixed at generate time by the generator (`'throw'` when omitted); `configure()` ignores it. */ errorMode?: 'throw' | 'result'; + /** + * How each call spells its inputs: `'grouped'` (the default) namespaces them by layer — + * `{ path, query, headers, cookies, body }` — and `'flat'` takes one merged object. + * Fixed at generate time, like `errorMode`, because it shapes the static types. + */ + argsStyle?: 'grouped' | 'flat'; onRequest?: Middleware['onRequest']; onResponse?: Middleware['onResponse']; onError?: Middleware['onError']; diff --git a/tests/e2e/generate-client/args-grouped.test.ts b/tests/e2e/generate-client/args-grouped.test.ts index 0ec1662d3e..ab0c845536 100644 --- a/tests/e2e/generate-client/args-grouped.test.ts +++ b/tests/e2e/generate-client/args-grouped.test.ts @@ -46,11 +46,11 @@ describe('generate-client end-to-end (--args-style grouped)', () => { expect(src).toMatch(/export const \{ [^}]*getOrderById[^}]* \} = client;/); // The grouped `Variables` aliases are still emitted for consumers. expect(src).toContain('export type GetOrderByIdVariables = {'); - // getOrderById's grouped args carry the path param as a member in Ops. - expect(src).toMatch(/getOrderById: \{\s*args: \{[\s\S]*?orderId: string;/); + // getOrderById's args carry the path parameter inside its own layer. + expect(src).toMatch(/getOrderById: \{\s*args: \{\s*path: GetOrderByIdPath;/); - // No flat positional sugar leaks through in grouped mode. - expect(src).not.toContain('export const getOrderById = (orderId: string'); + // Nothing wraps the method: the export IS the method. + expect(src).not.toContain('=> client.getOrderById('); }, 90_000); test('the grouped-style client type-checks under strict mode with no unused locals', () => { diff --git a/tests/e2e/generate-client/auth.test.ts b/tests/e2e/generate-client/auth.test.ts index 85b04621a3..0fb0fbc3a6 100644 --- a/tests/e2e/generate-client/auth.test.ts +++ b/tests/e2e/generate-client/auth.test.ts @@ -111,7 +111,7 @@ describe('generate-client auth breadth (auth.yaml)', () => { client.auth.bearer(async () => 'tok'); await getBearer(); client.auth.apiKey('QueryKey', 'secret-key'); - await getQuery({ limit: 5 }); + await getQuery({ query: { limit: 5 } }); await new Promise((r) => server.close(() => r())); process.stdout.write(JSON.stringify(captured)); } diff --git a/tests/e2e/generate-client/base-consumer/index-cancel.ts b/tests/e2e/generate-client/base-consumer/index-cancel.ts index f011811716..7a949c8ec1 100644 --- a/tests/e2e/generate-client/base-consumer/index-cancel.ts +++ b/tests/e2e/generate-client/base-consumer/index-cancel.ts @@ -2,7 +2,7 @@ import { getSlowPet } from './api.js'; async function main(): Promise { const controller = new AbortController(); - const promise = getSlowPet(1, { signal: controller.signal }); + const promise = getSlowPet({ path: { id: 1 } }, { signal: controller.signal }); setTimeout(() => controller.abort(), 100); try { await promise; diff --git a/tests/e2e/generate-client/base-consumer/index.ts b/tests/e2e/generate-client/base-consumer/index.ts index 08f9b3b2df..3c1187d2db 100644 --- a/tests/e2e/generate-client/base-consumer/index.ts +++ b/tests/e2e/generate-client/base-consumer/index.ts @@ -1,14 +1,14 @@ import { createPet, getPetById, listPets } from './api.js'; async function main(): Promise { - const pet = await getPetById(1); + const pet = await getPetById({ path: { id: 1 } }); // deepObject query param: the object is serialized as filter[name]=…&filter[status]=… - const filtered = await listPets({ filter: { name: 'rex', status: 'available' } }); + const filtered = await listPets({ query: { filter: { name: 'rex', status: 'available' } } }); // Bucket C: the create body is `Omit`, so the readOnly server-assigned // `id` is neither required nor accepted — this call compiles without it. - const created = await createPet({ name: 'rex', status: 'available' }); + const created = await createPet({ body: { name: 'rex', status: 'available' } }); // Bucket B: `metadata` is a free-form record (`{ [key: string]: unknown }`), so an // arbitrary key is accessible. Were it emitted as `{}`, this line would not compile. diff --git a/tests/e2e/generate-client/base.test.ts b/tests/e2e/generate-client/base.test.ts index cc75f0a096..a303edf475 100644 --- a/tests/e2e/generate-client/base.test.ts +++ b/tests/e2e/generate-client/base.test.ts @@ -46,13 +46,13 @@ describe('generate-client base consumer (single-file output)', () => { const generated = readFileSync(generatedFile, 'utf-8'); expect(generated).toContain('export type Pet'); expect(generated).toContain('export class ApiError'); - // The descriptor wiring with the embedded runtime, plus flat call sugar per operation. + // The descriptor wiring with the embedded runtime, plus one binding per operation. expect(generated).toContain('// ─── Embedded runtime'); expect(generated).toContain('as const satisfies Record'); expect(generated).toContain('export const { configure, use } = client;'); - expect(generated).toContain('export const getPetById = (OPERATIONS, { serverUrl: "http://localhost:3102", clientHeader: "redocly-client-generator" });' diff --git a/tests/e2e/generate-client/cafe-consumer/index-configure.ts b/tests/e2e/generate-client/cafe-consumer/index-configure.ts index 9a7f2013c2..ee03d27cb4 100644 --- a/tests/e2e/generate-client/cafe-consumer/index-configure.ts +++ b/tests/e2e/generate-client/cafe-consumer/index-configure.ts @@ -37,18 +37,22 @@ async function main(): Promise { // 1) Baseline: the file was generated with --server-url ${CAFE_BASE}, so the first // call should succeed against the mock server. - results.push(await step('initial-call-against-mock', () => listMenuItems({ limit: 1 }))); + results.push( + await step('initial-call-against-mock', () => listMenuItems({ query: { limit: 1 } })) + ); // 2) Flip serverUrl to an unreachable host. The same operation should now fail to // connect. This is the proof that configure() actually mutated the instance config. configure({ serverUrl: UNREACHABLE }); results.push( - await step('call-after-configure-to-unreachable', () => listMenuItems({ limit: 1 })) + await step('call-after-configure-to-unreachable', () => listMenuItems({ query: { limit: 1 } })) ); // 3) Flip serverUrl back to the live mock and confirm the config restored cleanly. configure({ serverUrl: liveBase }); - results.push(await step('call-after-configure-restored', () => listMenuItems({ limit: 1 }))); + results.push( + await step('call-after-configure-restored', () => listMenuItems({ query: { limit: 1 } })) + ); process.stdout.write(JSON.stringify(results, null, 2) + '\n'); } diff --git a/tests/e2e/generate-client/cafe-consumer/index.ts b/tests/e2e/generate-client/cafe-consumer/index.ts index 4c1f80a46a..cfe0d5d9c3 100644 --- a/tests/e2e/generate-client/cafe-consumer/index.ts +++ b/tests/e2e/generate-client/cafe-consumer/index.ts @@ -46,7 +46,7 @@ async function main(): Promise { results.push( await step('listMenuItems', () => - listMenuItems({ after: 'cursor1', limit: 5, sort: '-name', search: 'coffee' }) + listMenuItems({ query: { after: 'cursor1', limit: 5, sort: '-name', search: 'coffee' } }) ) ); @@ -58,18 +58,21 @@ async function main(): Promise { form.append('category', 'beverage'); form.append('volume', '250'); form.append('containsCaffeine', 'true'); - return createMenuItem(form); + return createMenuItem({ body: form }); }) ); results.push( - await step('deleteMenuItem', () => deleteMenuItem('prd_01h1s5z6vf2mm1mz3hevnn9va7')) + await step('deleteMenuItem', () => + deleteMenuItem({ path: { menuItemId: 'prd_01h1s5z6vf2mm1mz3hevnn9va7' } }) + ) ); results.push( await step('getMenuItemPhoto', async () => { - const result = await getMenuItemPhoto('prd_01h1s5z6vf2mm1mz3hevnn9va7', { - photoSize: 'medium', + const result = await getMenuItemPhoto({ + path: { menuItemId: 'prd_01h1s5z6vf2mm1mz3hevnn9va7' }, + query: { photoSize: 'medium' }, }); if (result instanceof Blob) { return { kind: 'blob', size: result.size, type: result.type }; @@ -78,49 +81,65 @@ async function main(): Promise { }) ); - results.push(await step('listOrders', () => listOrders({ filter: 'status:placed', limit: 5 }))); + results.push( + await step('listOrders', () => listOrders({ query: { filter: 'status:placed', limit: 5 } })) + ); results.push( await step('createOrder', () => createOrder({ - customerName: 'Ada Lovelace', - orderItems: [{ menuItemId: 'prd_01h1s5z6vf2mm1mz3hevnn9va7', quantity: 2 }], + body: { + customerName: 'Ada Lovelace', + orderItems: [{ menuItemId: 'prd_01h1s5z6vf2mm1mz3hevnn9va7', quantity: 2 }], + }, }) ) ); results.push( await step('getOrderById', () => - getOrderById('ord_01h1s5z6vf2mm1mz3hevnn9va7', { - 'X-Request-Id': '11111111-2222-3333-4444-555555555555', + getOrderById({ + path: { orderId: 'ord_01h1s5z6vf2mm1mz3hevnn9va7' }, + headers: { 'X-Request-Id': '11111111-2222-3333-4444-555555555555' }, }) ) ); results.push( await step('updateOrder', () => - updateOrder('ord_01h1s5z6vf2mm1mz3hevnn9va7', { status: OrderStatus.completed }) + updateOrder({ + path: { orderId: 'ord_01h1s5z6vf2mm1mz3hevnn9va7' }, + body: { status: OrderStatus.completed }, + }) ) ); - results.push(await step('deleteOrder', () => deleteOrder('ord_01h1s5z6vf2mm1mz3hevnn9va7'))); + results.push( + await step('deleteOrder', () => + deleteOrder({ path: { orderId: 'ord_01h1s5z6vf2mm1mz3hevnn9va7' } }) + ) + ); results.push( await step('listOrderItems', () => - listOrderItems({ filter: 'orderId:ord_01h1s5z6vf2mm1mz3hevnn9va7' }) + listOrderItems({ query: { filter: 'orderId:ord_01h1s5z6vf2mm1mz3hevnn9va7' } }) ) ); results.push( - await step('getRevenue', () => getRevenue({ startDate: '2026-01-01', endDate: '2026-01-31' })) + await step('getRevenue', () => + getRevenue({ query: { startDate: '2026-01-01', endDate: '2026-01-31' } }) + ) ); results.push( await step('registerOAuth2Client', () => registerOAuth2Client({ - name: 'demo-client', - scopes: ['menu:read', 'orders:read'], - grantTypes: ['client_credentials'], + body: { + name: 'demo-client', + scopes: ['menu:read', 'orders:read'], + grantTypes: ['client_credentials'], + }, }) ) ); @@ -129,7 +148,7 @@ async function main(): Promise { // type guards and confirm they agree with the raw discriminant. results.push( await step('menuItemGuards', async () => { - const list = await listMenuItems({}); + const list = await listMenuItems(); const item = list.items[0]; const category = (item as { category?: string }).category; const beverage = isBeverage(item); diff --git a/tests/e2e/generate-client/cafe.snapshot.ts b/tests/e2e/generate-client/cafe.snapshot.ts index 72fa62ce91..40f97ba039 100644 --- a/tests/e2e/generate-client/cafe.snapshot.ts +++ b/tests/e2e/generate-client/cafe.snapshot.ts @@ -414,7 +414,7 @@ export function isDessert(value: MenuItem): value is Dessert { export type ListMenuItemsResult = MenuItemList; -export type ListMenuItemsParams = { +export type ListMenuItemsQuery = { /** * Use the `endCursor` as a value for the `after` parameter to get the next page. */ @@ -466,7 +466,7 @@ export type ListMenuItemsParams = { }; export type ListMenuItemsVariables = { - params?: ListMenuItemsParams; + query?: ListMenuItemsQuery; }; export type CreateMenuItemResult = MenuItem; @@ -479,7 +479,7 @@ export type CreateMenuItemVariables = { export type DeleteMenuItemResult = void; -export type DeleteMenuItemVariables = { +export type DeleteMenuItemPath = { /** * ID of the menu item to retrieve. * @pattern ^prd_[0-9abcdefghjkmnpqrstvwxyz]{26}$ @@ -487,9 +487,21 @@ export type DeleteMenuItemVariables = { menuItemId: string; }; +export type DeleteMenuItemVariables = { + path: DeleteMenuItemPath; +}; + export type GetMenuItemPhotoResult = Blob | string; -export type GetMenuItemPhotoParams = { +export type GetMenuItemPhotoPath = { + /** + * ID of the menu item to retrieve. + * @pattern ^prd_[0-9abcdefghjkmnpqrstvwxyz]{26}$ + */ + menuItemId: string; +}; + +export type GetMenuItemPhotoQuery = { /** * Photo size to retrieve. */ @@ -497,17 +509,13 @@ export type GetMenuItemPhotoParams = { }; export type GetMenuItemPhotoVariables = { - /** - * ID of the menu item to retrieve. - * @pattern ^prd_[0-9abcdefghjkmnpqrstvwxyz]{26}$ - */ - menuItemId: string; - params?: GetMenuItemPhotoParams; + path: GetMenuItemPhotoPath; + query?: GetMenuItemPhotoQuery; }; export type ListOrdersResult = OrderList; -export type ListOrdersParams = { +export type ListOrdersQuery = { /** * Filters the collection items using space-separated `field:value` pairs. * @@ -559,7 +567,7 @@ export type ListOrdersParams = { }; export type ListOrdersVariables = { - params?: ListOrdersParams; + query?: ListOrdersQuery; }; export type CreateOrderResult = Order; @@ -572,6 +580,14 @@ export type CreateOrderVariables = { export type GetOrderByIdResult = Order; +export type GetOrderByIdPath = { + /** + * ID of the order to retrieve. + * @pattern ^ord_[0-9abcdefghjkmnpqrstvwxyz]{26}$ + */ + orderId: string; +}; + export type GetOrderByIdHeaders = { /** * Optional client-supplied correlation ID, echoed in logs and traces. @@ -581,17 +597,27 @@ export type GetOrderByIdHeaders = { }; export type GetOrderByIdVariables = { + path: GetOrderByIdPath; + headers?: GetOrderByIdHeaders; +}; + +export type DeleteOrderResult = void; + +export type DeleteOrderPath = { /** * ID of the order to retrieve. * @pattern ^ord_[0-9abcdefghjkmnpqrstvwxyz]{26}$ */ orderId: string; - headers?: GetOrderByIdHeaders; }; -export type DeleteOrderResult = void; - export type DeleteOrderVariables = { + path: DeleteOrderPath; +}; + +export type UpdateOrderResult = Order; + +export type UpdateOrderPath = { /** * ID of the order to retrieve. * @pattern ^ord_[0-9abcdefghjkmnpqrstvwxyz]{26}$ @@ -599,24 +625,18 @@ export type DeleteOrderVariables = { orderId: string; }; -export type UpdateOrderResult = Order; - export type UpdateOrderBody = { status: OrderStatus; }; export type UpdateOrderVariables = { - /** - * ID of the order to retrieve. - * @pattern ^ord_[0-9abcdefghjkmnpqrstvwxyz]{26}$ - */ - orderId: string; + path: UpdateOrderPath; body?: UpdateOrderBody; }; export type ListOrderItemsResult = OrderItem[]; -export type ListOrderItemsParams = { +export type ListOrderItemsQuery = { /** * Filters the collection items using space-separated `field:value` pairs. * @@ -638,12 +658,12 @@ export type ListOrderItemsParams = { }; export type ListOrderItemsVariables = { - params?: ListOrderItemsParams; + query?: ListOrderItemsQuery; }; export type GetRevenueResult = RevenueStatistics; -export type GetRevenueParams = { +export type GetRevenueQuery = { /** * Start date for the revenue calculation period (ISO 8601 datetime format). * Defaults to 30 days ago if not provided. @@ -659,7 +679,7 @@ export type GetRevenueParams = { }; export type GetRevenueVariables = { - params?: GetRevenueParams; + query?: GetRevenueQuery; }; export type RegisterOAuth2ClientResult = OAuth2Client; @@ -677,7 +697,7 @@ export type RegisterOAuth2ClientVariables = { export type Ops = { listMenuItems: { args: { - params?: ListMenuItemsParams; + query?: ListMenuItemsQuery; }; result: ListMenuItemsResult; }; @@ -689,28 +709,20 @@ export type Ops = { }; deleteMenuItem: { args: { - /** - * ID of the menu item to retrieve. - * @pattern ^prd_[0-9abcdefghjkmnpqrstvwxyz]{26}$ - */ - menuItemId: string; + path: DeleteMenuItemPath; }; result: DeleteMenuItemResult; }; getMenuItemPhoto: { args: { - /** - * ID of the menu item to retrieve. - * @pattern ^prd_[0-9abcdefghjkmnpqrstvwxyz]{26}$ - */ - menuItemId: string; - params?: GetMenuItemPhotoParams; + path: GetMenuItemPhotoPath; + query?: GetMenuItemPhotoQuery; }; result: GetMenuItemPhotoResult; }; listOrders: { args: { - params?: ListOrdersParams; + query?: ListOrdersQuery; }; result: ListOrdersResult; }; @@ -722,45 +734,33 @@ export type Ops = { }; getOrderById: { args: { - /** - * ID of the order to retrieve. - * @pattern ^ord_[0-9abcdefghjkmnpqrstvwxyz]{26}$ - */ - orderId: string; + path: GetOrderByIdPath; headers?: GetOrderByIdHeaders; }; result: GetOrderByIdResult; }; deleteOrder: { args: { - /** - * ID of the order to retrieve. - * @pattern ^ord_[0-9abcdefghjkmnpqrstvwxyz]{26}$ - */ - orderId: string; + path: DeleteOrderPath; }; result: DeleteOrderResult; }; updateOrder: { args: { - /** - * ID of the order to retrieve. - * @pattern ^ord_[0-9abcdefghjkmnpqrstvwxyz]{26}$ - */ - orderId: string; + path: UpdateOrderPath; body?: UpdateOrderBody; }; result: UpdateOrderResult; }; listOrderItems: { args: { - params?: ListOrderItemsParams; + query?: ListOrderItemsQuery; }; result: ListOrderItemsResult; }; getRevenue: { args: { - params?: GetRevenueParams; + query?: GetRevenueQuery; }; result: GetRevenueResult; }; @@ -1003,6 +1003,12 @@ export type ClientConfig = { auth?: AuthCredentials; /** Fixed at generate time by the generator (`'throw'` when omitted); `configure()` ignores it. */ errorMode?: 'throw' | 'result'; + /** + * How each call spells its inputs: `'grouped'` (the default) namespaces them by layer — + * `{ path, query, headers, cookies, body }` — and `'flat'` takes one merged object. + * Fixed at generate time, like `errorMode`, because it shapes the static types. + */ + argsStyle?: 'grouped' | 'flat'; onRequest?: Middleware['onRequest']; onResponse?: Middleware['onResponse']; onError?: Middleware['onError']; @@ -1762,14 +1768,66 @@ type Capabilities = SendCapabilities & { }; }; -/** The grouped args wire shape: path params by name plus the `params`/`body`/`headers`/`cookies` slots. */ +/** + * One call's inputs, namespaced by transport layer. `argsStyle: 'flat'` clients accept the + * merged form instead (every parameter and body property at one level) — `namespaceArgs` + * converts it to this shape before anything downstream reads it. + */ type OperationArgs = { - params?: Record; + path?: Record; + query?: Record; body?: unknown; headers?: Record; cookies?: Record; } & Record; +/** The five layer keys, and the only top-level keys a namespaced call may carry. */ +const LAYERS: readonly string[] = ['path', 'query', 'body', 'headers', 'cookies']; + +/** Where a declared parameter's `in` value puts it. */ +const LAYER_OF: Record = { + path: 'path', + query: 'query', + header: 'headers', + cookie: 'cookies', +}; + +/** + * Merged (`argsStyle: 'flat'`) args → the namespaced shape. A key that names a declared + * parameter goes to that parameter's layer; anything else is a property of the request + * body, which is how a flat call spells an object body. `body` stays reserved for the + * operations a flat call cannot merge (an array, a scalar, or a binary body). + */ +function namespaceArgs(op: OperationDescriptor, args: OperationArgs): OperationArgs { + const layers: Record> = {}; + let body: unknown; + let properties: Record | undefined; + const layerOfParam = new Map((op.params ?? []).map((param) => [param.name, param.in])); + for (const [key, value] of Object.entries(args)) { + const layer = LAYER_OF[layerOfParam.get(key) ?? '']; + if (layer !== undefined) { + (layers[layer] ??= {})[key] = value; + } else if (key === 'body' && op.body !== undefined) { + body = value; + } else if (op.body !== undefined) { + (properties ??= {})[key] = value; + } else { + throw new TypeError( + `Unknown argument "${key}" for operation "${op.id}": it names no declared parameter, and the operation takes no request body.` + ); + } + } + const namespaced: OperationArgs = {}; + if (layers.path) namespaced.path = layers.path; + // The flat surface types every query value, so the collected bag is one by construction. + if (layers.query) namespaced.query = layers.query as Record; + if (layers.headers) namespaced.headers = layers.headers; + if (layers.cookies) namespaced.cookies = layers.cookies; + if (properties !== undefined) namespaced.body = properties; + else if (body !== undefined) namespaced.body = body; + return namespaced; +} + /** The response reader implied by the descriptor (before any per-call `parseAs` override). */ /** * The `Accept` header matching how the response will be read — a blob/text operation @@ -1792,31 +1850,30 @@ function kindFor(op: OperationDescriptor): ParseAs | 'void' { return 'auto'; } -/** Route the grouped args by the descriptor: path values, query object, body, extra headers, cookies. */ +/** The call's inputs in namespaced form, converting first on a flat-style client. */ +function inputOf( + op: OperationDescriptor, + args: OperationArgs, + config: ClientConfig +): OperationArgs { + return config.argsStyle === 'flat' ? namespaceArgs(op, args) : args; +} + +/** Route the namespaced args to the request pieces. */ function splitArgs(op: OperationDescriptor, args: OperationArgs) { - const path: Record = {}; - const pathNames = new Set(); - for (const param of op.params ?? []) { - if (param.in === 'path') { - pathNames.add(param.name); - path[param.name] = args[param.name]; - } - } - // An unknown top-level key can only be a bug (usually a flat-style call shape passed - // to a grouped client: `{ limit: 10 }` instead of `{ params: { limit: 10 } }`). - // TypeScript catches it, but transpilers that skip type-checking would otherwise - // ship a request that silently drops the value — fail the call loudly instead. + // An unknown layer key can only be a bug (usually flat-style args on a namespaced + // client). TypeScript catches it, but a transpiler that skips type-checking would + // otherwise ship a request that silently drops the value — fail the call loudly. for (const key of Object.keys(args)) { - if (key === 'params' || key === 'body' || key === 'headers' || key === 'cookies') continue; - if (pathNames.has(key)) continue; - throw new TypeError( - `Unknown argument "${key}" for operation "${op.id}". Query parameters go under params: { … } and the request body under body; valid keys are params, body, headers, cookies` + - (pathNames.size > 0 ? `, and the path parameters (${[...pathNames].join(', ')}).` : '.') - ); + if (!LAYERS.includes(key)) { + throw new TypeError( + `Unknown argument "${key}" for operation "${op.id}". Inputs are grouped by layer: ${LAYERS.join(', ')}.` + ); + } } return { - path, - query: args.params, + path: args.path ?? {}, + query: args.query, body: args.body, headers: args.headers, cookies: args.cookies, @@ -2093,7 +2150,8 @@ function createClientCore< for (const [name, op] of Object.entries(operations)) { if (op.responseKind === 'sse') { - const method = (args: OperationArgs = {}, init: SseOptions = {}) => { + const method = (given: OperationArgs = {}, init: SseOptions = {}) => { + const args = inputOf(op, given, config); if (!caps.sse) { throw new Error(`SSE capability not wired: cannot stream operation "${op.id}"`); } @@ -2117,8 +2175,13 @@ function createClientCore< Object.defineProperty(method, 'operationId', { value: op.id }); client[name] = method; } else { - const method = (args: OperationArgs = {}, init: RequestOptions = {}) => + // `raw` takes namespaced args; `method` is the public entry that accepts whichever + // style the client was generated with. The iterators namespace once and then drive + // `raw`, so a flat call is never converted twice. + const raw = (args: OperationArgs = {}, init: RequestOptions = {}) => execute(config, op, args, init, caps); + const method = (args: OperationArgs = {}, init: RequestOptions = {}) => + raw(inputOf(op, args, config), init); Object.defineProperty(method, 'name', { value: name }); Object.defineProperty(method, 'operationId', { value: op.id }); const spec = op.pagination; @@ -2137,31 +2200,42 @@ function createClientCore< pages: (args?: OperationArgs, init?: RequestOptions) => paginateCapability(caps, op).pagesByLink( linkPageCall(config, op, caps), - args, + inputOf(op, args ?? {}, config), init ), items: (args?: OperationArgs, init?: RequestOptions) => paginateCapability(caps, op).itemsByLink( linkPageCall(config, op, caps), spec, - args, + inputOf(op, args ?? {}, config), init ), }) : Object.assign(method, { pages: (args?: OperationArgs, init?: RequestOptions) => - paginateCapability(caps, op).pages(pageCall(method, config), spec, args, init), + paginateCapability(caps, op).pages( + pageCall(raw, config), + spec, + inputOf(op, args ?? {}, config), + init + ), items: (args?: OperationArgs, init?: RequestOptions) => - paginateCapability(caps, op).items(pageCall(method, config), spec, args, init), + paginateCapability(caps, op).items( + pageCall(raw, config), + spec, + inputOf(op, args ?? {}, config), + init + ), }); } } // Core members are assigned AFTER the operation loop — they win over colliding op names. client.configure = (next: ClientConfig): void => { - // `errorMode` is fixed at generate time (it shapes the static types); flipping it at - // runtime would silently desync return shapes from `Client`, so it is ignored. - const { errorMode: _fixed, auth, ...rest } = next; + // `errorMode` and `argsStyle` are fixed at generate time (they shape the static types); + // flipping either at runtime would silently desync the calls from `Client`, so both + // are ignored here. + const { errorMode: _fixedMode, argsStyle: _fixedStyle, auth, ...rest } = next; Object.assign(config, rest); // `auth` merges into existing credentials (like the `auth.*` setters) rather than // replacing wholesale — so `configure({ auth: { bearer } })` keeps a previously set @@ -2214,158 +2288,4 @@ export function createClient< export const client = createClient(OPERATIONS, { serverUrl: "https://api.cafe.redocly.com", clientHeader: "redocly-client-generator" }); export const { configure, use } = client; -export const listMenuItems = (params: { - /** - * Use the `endCursor` as a value for the `after` parameter to get the next page. - */ - after?: string; - /** - * Use the `startCursor` as a value for the `before` parameter to get the next page. - */ - before?: string; - /** - * To sort by id in descending order use `-id`. - * To sort by id in ascending order use `id`. - */ - sort?: string; - /** - * Filters the collection items using space-separated `field:value` pairs. - * - * **Format:** `field1:value1 field2:value2` - * - * **Supported operators:** - * - `field:value` - Exact match - * - `field:value1,value2` - Match any of the comma-separated values (OR) - * - Time ranges: Use `30d` (30 days), `7d` (7 days), `1h` (1 hour), etc. - * - * **Examples:** - * - `status:placed` - Filter by single status. - * - `status:placed,completed` - Filter by multiple statuses. - * - `createdAt:30d` - Filter orders created in the last 30 days. - * - `orderId:ord_01h1s5z6vf2mm1mz3hevnn9va7` - Filter by specific order ID. - * - `status:placed createdAt:7d` - Combine multiple filters. - */ - filter?: string; - /** - * Performs a case-insensitive text search across relevant fields in the collection. - * - * **Fields searched depend on the endpoint:** - * - **Menu items:** `name`, `photoTextDescription` - * - **Orders:** `customerName`, `id` - * - * Returns items where any of the searchable fields contain the search term as a substring. - */ - search?: string; - /** - * Use to return a number of results per page. - * If there is more data, use in combination with `after` to page through the data. - * @minimum 1 - * @maximum 100 - */ - limit?: number; -} = {}, init?: I): Promise, I>> => client.listMenuItems({ params }, init) as Promise, I>>; -export const createMenuItem = (body: FormData, init?: I): Promise, I>> => client.createMenuItem({ body }, init) as Promise, I>>; -export const deleteMenuItem = (menuItemId: string, init?: I): Promise, I>> => client.deleteMenuItem({ menuItemId }, init) as Promise, I>>; -export const getMenuItemPhoto = (menuItemId: string, params: { - /** - * Photo size to retrieve. - */ - photoSize?: "thumbnail" | "medium" | "large"; -} = {}, init?: I): Promise, I>> => client.getMenuItemPhoto({ menuItemId, params }, init) as Promise, I>>; -export const listOrders = (params: { - /** - * Filters the collection items using space-separated `field:value` pairs. - * - * **Format:** `field1:value1 field2:value2` - * - * **Supported operators:** - * - `field:value` - Exact match - * - `field:value1,value2` - Match any of the comma-separated values (OR) - * - Time ranges: Use `30d` (30 days), `7d` (7 days), `1h` (1 hour), etc. - * - * **Examples:** - * - `status:placed` - Filter by single status. - * - `status:placed,completed` - Filter by multiple statuses. - * - `createdAt:30d` - Filter orders created in the last 30 days. - * - `orderId:ord_01h1s5z6vf2mm1mz3hevnn9va7` - Filter by specific order ID. - * - `status:placed createdAt:7d` - Combine multiple filters. - */ - filter?: string; - /** - * To sort by id in descending order use `-id`. - * To sort by id in ascending order use `id`. - */ - sort?: string; - /** - * Use to return a number of results per page. - * If there is more data, use in combination with `after` to page through the data. - * @minimum 1 - * @maximum 100 - */ - limit?: number; - /** - * Use the `endCursor` as a value for the `after` parameter to get the next page. - */ - after?: string; - /** - * Use the `startCursor` as a value for the `before` parameter to get the next page. - */ - before?: string; - /** - * Performs a case-insensitive text search across relevant fields in the collection. - * - * **Fields searched depend on the endpoint:** - * - **Menu items:** `name`, `photoTextDescription` - * - **Orders:** `customerName`, `id` - * - * Returns items where any of the searchable fields contain the search term as a substring. - */ - search?: string; -} = {}, init?: I): Promise, I>> => client.listOrders({ params }, init) as Promise, I>>; -export const createOrder = (body: Omit, init?: I): Promise, I>> => client.createOrder({ body }, init) as Promise, I>>; -export const getOrderById = (orderId: string, headers: { - /** - * Optional client-supplied correlation ID, echoed in logs and traces. - * @format uuid - */ - "X-Request-Id"?: string; -} = {}, init?: I): Promise, I>> => client.getOrderById({ orderId, headers }, init) as Promise, I>>; -export const deleteOrder = (orderId: string, init?: I): Promise, I>> => client.deleteOrder({ orderId }, init) as Promise, I>>; -export const updateOrder = (orderId: string, body?: { - status: OrderStatus; -}, init?: I): Promise, I>> => client.updateOrder({ orderId, body }, init) as Promise, I>>; -export const listOrderItems = (params: { - /** - * Filters the collection items using space-separated `field:value` pairs. - * - * **Format:** `field1:value1 field2:value2` - * - * **Supported operators:** - * - `field:value` - Exact match - * - `field:value1,value2` - Match any of the comma-separated values (OR) - * - Time ranges: Use `30d` (30 days), `7d` (7 days), `1h` (1 hour), etc. - * - * **Examples:** - * - `status:placed` - Filter by single status. - * - `status:placed,completed` - Filter by multiple statuses. - * - `createdAt:30d` - Filter orders created in the last 30 days. - * - `orderId:ord_01h1s5z6vf2mm1mz3hevnn9va7` - Filter by specific order ID. - * - `status:placed createdAt:7d` - Combine multiple filters. - */ - filter?: string; -} = {}, init?: I): Promise, I>> => client.listOrderItems({ params }, init) as Promise, I>>; -export const getRevenue = (params: { - /** - * Start date for the revenue calculation period (ISO 8601 datetime format). - * Defaults to 30 days ago if not provided. - * @format date - */ - startDate?: string; - /** - * End date for the revenue calculation period (ISO 8601 datetime format). - * Defaults to current time if not provided. - * @format date - */ - endDate?: string; -} = {}, init?: I): Promise, I>> => client.getRevenue({ params }, init) as Promise, I>>; -export const registerOAuth2Client = (body: RegisterClientObject, init?: I): Promise, I>> => client.registerOAuth2Client({ body }, init) as Promise, I>>; +export const { listMenuItems, createMenuItem, deleteMenuItem, getMenuItemPhoto, listOrders, createOrder, getOrderById, deleteOrder, updateOrder, listOrderItems, getRevenue, registerOAuth2Client } = client; diff --git a/tests/e2e/generate-client/cafe.test.ts b/tests/e2e/generate-client/cafe.test.ts index 2ada6eb64f..d94c8df915 100644 --- a/tests/e2e/generate-client/cafe.test.ts +++ b/tests/e2e/generate-client/cafe.test.ts @@ -133,7 +133,7 @@ describe('generate-client end-to-end (cafe.yaml)', () => { expect(generated).toContain('export type OAuth2Client = {'); }); - test('generated file declares one flat call-sugar function per operation', () => { + test('generated file exports one binding per operation', () => { const expected = [ 'listMenuItems', 'createMenuItem', @@ -142,16 +142,14 @@ describe('generate-client end-to-end (cafe.yaml)', () => { 'listOrders', 'createOrder', 'getOrderById', - 'updateOrder', 'deleteOrder', + 'updateOrder', 'listOrderItems', 'getRevenue', 'registerOAuth2Client', ]; - for (const name of expected) { - // Plain arrow, generic envelope-aware arrow, or Object.assign-wrapped (paginated). - expect(generated).toMatch(new RegExp(`export const ${name} = (Object\\.assign\\()?[(<]`)); - } + // One destructure of the client: the exported name IS the method. + expect(generated).toContain(`export const { ${expected.join(', ')} } = client;`); }); test('exports an OPERATIONS descriptor map keyed by operationId (method + path template)', () => { @@ -177,18 +175,18 @@ describe('generate-client end-to-end (cafe.yaml)', () => { expect(generated).toContain('export const { configure, use } = client;'); }); - test('generated file uses ergonomic signatures (positional path params + params object + body)', () => { - // Throw-mode flat sugar is generic over `init` (envelope-aware return type). - const sugar = ''; - expect(generated).toContain(`export const deleteMenuItem = ${sugar}(menuItemId: string,`); - expect(generated).toContain(`export const getMenuItemPhoto = ${sugar}(menuItemId: string,`); - expect(generated).toContain(`export const updateOrder = ${sugar}(orderId: string,`); - expect(generated).toContain(`export const listMenuItems = ${sugar}(params:`); + test('inputs are grouped by layer, one type per layer', () => { + expect(generated).toContain('export type DeleteMenuItemPath = {'); + expect(generated).toContain('export type GetMenuItemPhotoVariables = {'); + expect(generated).toContain(' path: GetMenuItemPhotoPath;'); + expect(generated).toContain(' query?: GetMenuItemPhotoQuery;'); + expect(generated).toContain('export type UpdateOrderVariables = {'); + expect(generated).toContain('export type ListMenuItemsQuery = {'); // readOnly fields are dropped from the create body (Bucket C). expect(generated).toContain( - `export const createOrder = ${sugar}(body: Omit,` + 'export type CreateOrderBody = Omit;' ); - expect(generated).toContain(`export const createMenuItem = ${sugar}(body: FormData,`); + expect(generated).toContain('export type CreateMenuItemBody = FormData;'); }); // Named string enums get a runtime const-object companion by default, which the diff --git a/tests/e2e/generate-client/envelope.test.ts b/tests/e2e/generate-client/envelope.test.ts index fa51e6a6b3..3021ca0f17 100644 --- a/tests/e2e/generate-client/envelope.test.ts +++ b/tests/e2e/generate-client/envelope.test.ts @@ -42,14 +42,14 @@ describe('generate-client envelope', () => { '', // Options that never mention `envelope` keep the plain body type. 'export async function bodyWithOptions() {', - " const rows = await listCustomers({ headers: { 'X-Trace': '1' } });", + " const rows = await listCustomers({}, { headers: { 'X-Trace': '1' } });", " const viaClientRows = await client.listCustomers({}, { parseAs: 'json' });", ' return rows.map((row) => row.id).concat(viaClientRows.map((row) => row.id));', '}', '', // Flat sugar: no-input ops take `init` as the first argument. 'export async function withEnvelope() {', - ' const { data, headers, response } = await listCustomers({ envelope: true });', + ' const { data, headers, response } = await listCustomers({}, { envelope: true });', ' const total: number = headers.paginationTotal;', ' const flag: boolean | undefined = headers.xFlag;', ' const secure: boolean = headers._3dSecure;', @@ -65,7 +65,7 @@ describe('generate-client envelope', () => { '}', '', 'export async function bodylessResponse() {', - " const { data, headers } = await createCustomer('cus_1', { envelope: true });", + " const { data, headers } = await createCustomer({ path: { id: 'cus_1' } }, { envelope: true });", ' const nothing: void = data;', ' const location: string = headers.location;', ' return { nothing, location };', @@ -73,7 +73,7 @@ describe('generate-client envelope', () => { '', 'export async function widenedEnvelopeOption() {', ' const options = { envelope: true };', - ' const result = await listCustomers(options);', + ' const result = await listCustomers({}, options);', " return 'response' in result ? result.data.length : result.length;", '}', '', diff --git a/tests/e2e/generate-client/error-mode.test.ts b/tests/e2e/generate-client/error-mode.test.ts index e06ffb4bb5..43c2fe5bf9 100644 --- a/tests/e2e/generate-client/error-mode.test.ts +++ b/tests/e2e/generate-client/error-mode.test.ts @@ -38,7 +38,7 @@ describe('generate-client error mode', () => { expect(existsSync(out)).toBe(true); const generated = readFileSync(out, 'utf-8'); - expect(generated).toContain('export const getThing = ('); + expect(generated).toContain('export const { getThing } = client;'); expect(generated).toContain('result: Result;'); expect(generated).toContain('export type GetThingError = ProblemDetails;'); // The mode is baked into the client instance config (configure() cannot flip it). diff --git a/tests/e2e/generate-client/examples/configure-and-middleware/src/main.ts b/tests/e2e/generate-client/examples/configure-and-middleware/src/main.ts index 57cd824aee..01ce54104f 100644 --- a/tests/e2e/generate-client/examples/configure-and-middleware/src/main.ts +++ b/tests/e2e/generate-client/examples/configure-and-middleware/src/main.ts @@ -89,9 +89,11 @@ use({ async function main() { // A header for this one call only goes in the trailing RequestOptions argument. const payments = await listPayments({}, { headers: { 'X-Request-Id': '42' } }); // 503 first, then retried to 200 - const payment = await createPayment({ amount: 4200, currency: 'EUR', reference: 'INV-17' }); + const payment = await createPayment({ + body: { amount: 4200, currency: 'EUR', reference: 'INV-17' }, + }); try { - await getPayment('pay_missing'); + await getPayment({ path: { paymentId: 'pay_missing' } }); } catch (error) { if (error instanceof ApiError) { // `error.body` is the decoded response body; per the spec's 4xx contract diff --git a/tests/e2e/generate-client/examples/custom-pagination/src/main.ts b/tests/e2e/generate-client/examples/custom-pagination/src/main.ts index 3917c324c6..c18c9c87d1 100644 --- a/tests/e2e/generate-client/examples/custom-pagination/src/main.ts +++ b/tests/e2e/generate-client/examples/custom-pagination/src/main.ts @@ -38,6 +38,8 @@ async function* paginate( } } -for await (const order of paginate((cursor) => searchOrders({ status: 'ready', cursor }))) { +for await (const order of paginate((cursor) => + searchOrders({ body: { status: 'ready', cursor } }) +)) { console.log(`search hit ${order.id}: ${order.drink}`); // `order` is `Order` — typed end to end } diff --git a/tests/e2e/generate-client/examples/ejected-generator/.claude/skills/client-generators/SKILL.md b/tests/e2e/generate-client/examples/ejected-generator/.claude/skills/client-generators/SKILL.md index ef717aabf5..3a0250145a 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,21 +96,22 @@ 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). | -| `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. | +| `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/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 8863cb1fd7..acfed7323e 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 @@ -28,6 +28,11 @@ extension — zero Composer dependencies. The namespace derives from the API tit - The `Client` class is NOT `final` — PHP test suites mock concrete classes (`createMock(Client::class)`), and `final` would force a wrapper interface on every consumer. Model classes stay `final`. +- **Every parameter is its own argument, so their names share one namespace** with the + arguments the method declares itself (`$body`, `$headers`, `$idempotencyKey`). Build them with + `uniqueIdentifiers(..., { taken: … })`: OpenAPI lets one operation use a name in two + locations (`id` in the path AND in the query), and PHP rejects a redefined parameter outright. The + wire name is untouched, so the request is unchanged. - **Naming:** classes PascalCase, properties/methods camelCase via `identifierFor(..., RESERVED_WORDS.php)`; reserved words get a trailing underscore. - **Enums** are native backed enums (string/int); other scalars stay aliases. diff --git a/tests/e2e/generate-client/examples/node-native/src/main.ts b/tests/e2e/generate-client/examples/node-native/src/main.ts index de372bc361..196751dcd0 100644 --- a/tests/e2e/generate-client/examples/node-native/src/main.ts +++ b/tests/e2e/generate-client/examples/node-native/src/main.ts @@ -5,7 +5,7 @@ // The import below uses a `.ts` extension for the same reason. import { listMenuItems } from './api/client.ts'; -const menu = await listMenuItems({ limit: 3 }); +const menu = await listMenuItems({ query: { limit: 3 } }); for (const item of menu.items) { console.log(`${item.name} — $${(item.price / 100).toFixed(2)}`); } diff --git a/tests/e2e/generate-client/examples/package-runtime/src/main.ts b/tests/e2e/generate-client/examples/package-runtime/src/main.ts index 0d074681f0..3d3c8af8b7 100644 --- a/tests/e2e/generate-client/examples/package-runtime/src/main.ts +++ b/tests/e2e/generate-client/examples/package-runtime/src/main.ts @@ -26,11 +26,14 @@ use({ async function main() { try { // A typed call through a generated free function… - const menu = await listMenuItems({ limit: 3 }); + const menu = await listMenuItems({ query: { limit: 3 } }); // …and one through the generated `client` instance (the same runtime underneath). const [first] = menu.items; const photo = first - ? await client.getMenuItemPhoto({ menuItemId: first.id, params: { photoSize: 'thumbnail' } }) + ? await client.getMenuItemPhoto({ + path: { menuItemId: first.id }, + query: { photoSize: 'thumbnail' }, + }) : undefined; const photoLine = photo instanceof Blob diff --git a/tests/e2e/generate-client/examples/pagination/src/main.ts b/tests/e2e/generate-client/examples/pagination/src/main.ts index 9a2b550f5c..62b2a47d47 100644 --- a/tests/e2e/generate-client/examples/pagination/src/main.ts +++ b/tests/e2e/generate-client/examples/pagination/src/main.ts @@ -34,12 +34,12 @@ configure({ fetch: canned }); // `.items()` walks every order across every page — the cursor plumbing is invisible, // and each `order` is the statically computed element type (`Order`). -for await (const order of listOrders.items({ limit: 20 })) { +for await (const order of listOrders.items({ query: { limit: 20 } })) { console.log(`${order.id}: ${order.drink} (${order.status})`); } // `.pages()` when you need page-level access (progress reporting, batch writes). let pageNumber = 0; -for await (const page of listOrders.pages({ limit: 20 })) { +for await (const page of listOrders.pages({ query: { limit: 20 } })) { console.log(`page ${++pageNumber}: ${page.orders.length} orders`); } diff --git a/tests/e2e/generate-client/examples/vendored-edge/worker.ts b/tests/e2e/generate-client/examples/vendored-edge/worker.ts index d395a7868a..46971f3f2e 100644 --- a/tests/e2e/generate-client/examples/vendored-edge/worker.ts +++ b/tests/e2e/generate-client/examples/vendored-edge/worker.ts @@ -14,15 +14,15 @@ export default { try { if (url.pathname === '/menu') { const menu = await client.listMenuItems({ - params: { search: url.searchParams.get('search') ?? undefined }, + query: { search: url.searchParams.get('search') ?? undefined }, }); return Response.json(menu.items); } const photo = url.pathname.match(/^\/photo\/(?[^/]+)$/); if (photo?.groups) { const image = await client.getMenuItemPhoto({ - menuItemId: photo.groups.menuItemId, - params: { photoSize: 'thumbnail' }, + path: { menuItemId: photo.groups.menuItemId }, + query: { photoSize: 'thumbnail' }, }); return image instanceof Blob ? new Response(image, { headers: { 'content-type': image.type } }) diff --git a/tests/e2e/generate-client/examples/zero-install-quickstart/src/api/client.ts b/tests/e2e/generate-client/examples/zero-install-quickstart/src/api/client.ts index 633a545c95..272d960b5f 100644 --- a/tests/e2e/generate-client/examples/zero-install-quickstart/src/api/client.ts +++ b/tests/e2e/generate-client/examples/zero-install-quickstart/src/api/client.ts @@ -45,7 +45,7 @@ export type OAuth2Client = { export type ListMenuItemsResult = MenuItemList; -export type ListMenuItemsParams = { +export type ListMenuItemsQuery = { /** * Case-insensitive substring match on item names. */ @@ -59,12 +59,19 @@ export type ListMenuItemsParams = { }; export type ListMenuItemsVariables = { - params?: ListMenuItemsParams; + query?: ListMenuItemsQuery; }; export type GetMenuItemPhotoResult = Blob | string; -export type GetMenuItemPhotoParams = { +export type GetMenuItemPhotoPath = { + /** + * ID of the menu item. + */ + menuItemId: string; +}; + +export type GetMenuItemPhotoQuery = { /** * Photo size to retrieve. */ @@ -72,11 +79,8 @@ export type GetMenuItemPhotoParams = { }; export type GetMenuItemPhotoVariables = { - /** - * ID of the menu item. - */ - menuItemId: string; - params?: GetMenuItemPhotoParams; + path: GetMenuItemPhotoPath; + query?: GetMenuItemPhotoQuery; }; export type RegisterOAuth2ClientResult = OAuth2Client; @@ -94,17 +98,14 @@ export type RegisterOAuth2ClientVariables = { export type Ops = { listMenuItems: { args: { - params?: ListMenuItemsParams; + query?: ListMenuItemsQuery; }; result: ListMenuItemsResult; }; getMenuItemPhoto: { args: { - /** - * ID of the menu item. - */ - menuItemId: string; - params?: GetMenuItemPhotoParams; + path: GetMenuItemPhotoPath; + query?: GetMenuItemPhotoQuery; }; result: GetMenuItemPhotoResult; }; @@ -338,6 +339,12 @@ export type ClientConfig = { auth?: AuthCredentials; /** Fixed at generate time by the generator (`'throw'` when omitted); `configure()` ignores it. */ errorMode?: 'throw' | 'result'; + /** + * How each call spells its inputs: `'grouped'` (the default) namespaces them by layer — + * `{ path, query, headers, cookies, body }` — and `'flat'` takes one merged object. + * Fixed at generate time, like `errorMode`, because it shapes the static types. + */ + argsStyle?: 'grouped' | 'flat'; onRequest?: Middleware['onRequest']; onResponse?: Middleware['onResponse']; onError?: Middleware['onError']; @@ -1034,14 +1041,66 @@ type Capabilities = SendCapabilities & { }; }; -/** The grouped args wire shape: path params by name plus the `params`/`body`/`headers`/`cookies` slots. */ +/** + * One call's inputs, namespaced by transport layer. `argsStyle: 'flat'` clients accept the + * merged form instead (every parameter and body property at one level) — `namespaceArgs` + * converts it to this shape before anything downstream reads it. + */ type OperationArgs = { - params?: Record; + path?: Record; + query?: Record; body?: unknown; headers?: Record; cookies?: Record; } & Record; +/** The five layer keys, and the only top-level keys a namespaced call may carry. */ +const LAYERS: readonly string[] = ['path', 'query', 'body', 'headers', 'cookies']; + +/** Where a declared parameter's `in` value puts it. */ +const LAYER_OF: Record = { + path: 'path', + query: 'query', + header: 'headers', + cookie: 'cookies', +}; + +/** + * Merged (`argsStyle: 'flat'`) args → the namespaced shape. A key that names a declared + * parameter goes to that parameter's layer; anything else is a property of the request + * body, which is how a flat call spells an object body. `body` stays reserved for the + * operations a flat call cannot merge (an array, a scalar, or a binary body). + */ +function namespaceArgs(op: OperationDescriptor, args: OperationArgs): OperationArgs { + const layers: Record> = {}; + let body: unknown; + let properties: Record | undefined; + const layerOfParam = new Map((op.params ?? []).map((param) => [param.name, param.in])); + for (const [key, value] of Object.entries(args)) { + const layer = LAYER_OF[layerOfParam.get(key) ?? '']; + if (layer !== undefined) { + (layers[layer] ??= {})[key] = value; + } else if (key === 'body' && op.body !== undefined) { + body = value; + } else if (op.body !== undefined) { + (properties ??= {})[key] = value; + } else { + throw new TypeError( + `Unknown argument "${key}" for operation "${op.id}": it names no declared parameter, and the operation takes no request body.` + ); + } + } + const namespaced: OperationArgs = {}; + if (layers.path) namespaced.path = layers.path; + // The flat surface types every query value, so the collected bag is one by construction. + if (layers.query) namespaced.query = layers.query as Record; + if (layers.headers) namespaced.headers = layers.headers; + if (layers.cookies) namespaced.cookies = layers.cookies; + if (properties !== undefined) namespaced.body = properties; + else if (body !== undefined) namespaced.body = body; + return namespaced; +} + /** The response reader implied by the descriptor (before any per-call `parseAs` override). */ /** * The `Accept` header matching how the response will be read — a blob/text operation @@ -1064,31 +1123,30 @@ function kindFor(op: OperationDescriptor): ParseAs | 'void' { return 'auto'; } -/** Route the grouped args by the descriptor: path values, query object, body, extra headers, cookies. */ +/** The call's inputs in namespaced form, converting first on a flat-style client. */ +function inputOf( + op: OperationDescriptor, + args: OperationArgs, + config: ClientConfig +): OperationArgs { + return config.argsStyle === 'flat' ? namespaceArgs(op, args) : args; +} + +/** Route the namespaced args to the request pieces. */ function splitArgs(op: OperationDescriptor, args: OperationArgs) { - const path: Record = {}; - const pathNames = new Set(); - for (const param of op.params ?? []) { - if (param.in === 'path') { - pathNames.add(param.name); - path[param.name] = args[param.name]; - } - } - // An unknown top-level key can only be a bug (usually a flat-style call shape passed - // to a grouped client: `{ limit: 10 }` instead of `{ params: { limit: 10 } }`). - // TypeScript catches it, but transpilers that skip type-checking would otherwise - // ship a request that silently drops the value — fail the call loudly instead. + // An unknown layer key can only be a bug (usually flat-style args on a namespaced + // client). TypeScript catches it, but a transpiler that skips type-checking would + // otherwise ship a request that silently drops the value — fail the call loudly. for (const key of Object.keys(args)) { - if (key === 'params' || key === 'body' || key === 'headers' || key === 'cookies') continue; - if (pathNames.has(key)) continue; - throw new TypeError( - `Unknown argument "${key}" for operation "${op.id}". Query parameters go under params: { … } and the request body under body; valid keys are params, body, headers, cookies` + - (pathNames.size > 0 ? `, and the path parameters (${[...pathNames].join(', ')}).` : '.') - ); + if (!LAYERS.includes(key)) { + throw new TypeError( + `Unknown argument "${key}" for operation "${op.id}". Inputs are grouped by layer: ${LAYERS.join(', ')}.` + ); + } } return { - path, - query: args.params, + path: args.path ?? {}, + query: args.query, body: args.body, headers: args.headers, cookies: args.cookies, @@ -1365,7 +1423,8 @@ function createClientCore< for (const [name, op] of Object.entries(operations)) { if (op.responseKind === 'sse') { - const method = (args: OperationArgs = {}, init: SseOptions = {}) => { + const method = (given: OperationArgs = {}, init: SseOptions = {}) => { + const args = inputOf(op, given, config); if (!caps.sse) { throw new Error(`SSE capability not wired: cannot stream operation "${op.id}"`); } @@ -1389,8 +1448,13 @@ function createClientCore< Object.defineProperty(method, 'operationId', { value: op.id }); client[name] = method; } else { - const method = (args: OperationArgs = {}, init: RequestOptions = {}) => + // `raw` takes namespaced args; `method` is the public entry that accepts whichever + // style the client was generated with. The iterators namespace once and then drive + // `raw`, so a flat call is never converted twice. + const raw = (args: OperationArgs = {}, init: RequestOptions = {}) => execute(config, op, args, init, caps); + const method = (args: OperationArgs = {}, init: RequestOptions = {}) => + raw(inputOf(op, args, config), init); Object.defineProperty(method, 'name', { value: name }); Object.defineProperty(method, 'operationId', { value: op.id }); const spec = op.pagination; @@ -1409,31 +1473,42 @@ function createClientCore< pages: (args?: OperationArgs, init?: RequestOptions) => paginateCapability(caps, op).pagesByLink( linkPageCall(config, op, caps), - args, + inputOf(op, args ?? {}, config), init ), items: (args?: OperationArgs, init?: RequestOptions) => paginateCapability(caps, op).itemsByLink( linkPageCall(config, op, caps), spec, - args, + inputOf(op, args ?? {}, config), init ), }) : Object.assign(method, { pages: (args?: OperationArgs, init?: RequestOptions) => - paginateCapability(caps, op).pages(pageCall(method, config), spec, args, init), + paginateCapability(caps, op).pages( + pageCall(raw, config), + spec, + inputOf(op, args ?? {}, config), + init + ), items: (args?: OperationArgs, init?: RequestOptions) => - paginateCapability(caps, op).items(pageCall(method, config), spec, args, init), + paginateCapability(caps, op).items( + pageCall(raw, config), + spec, + inputOf(op, args ?? {}, config), + init + ), }); } } // Core members are assigned AFTER the operation loop — they win over colliding op names. client.configure = (next: ClientConfig): void => { - // `errorMode` is fixed at generate time (it shapes the static types); flipping it at - // runtime would silently desync return shapes from `Client`, so it is ignored. - const { errorMode: _fixed, auth, ...rest } = next; + // `errorMode` and `argsStyle` are fixed at generate time (they shape the static types); + // flipping either at runtime would silently desync the calls from `Client`, so both + // are ignored here. + const { errorMode: _fixedMode, argsStyle: _fixedStyle, auth, ...rest } = next; Object.assign(config, rest); // `auth` merges into existing credentials (like the `auth.*` setters) rather than // replacing wholesale — so `configure({ auth: { bearer } })` keeps a previously set @@ -1486,22 +1561,4 @@ export function createClient< export const client = createClient(OPERATIONS, { serverUrl: "https://api.cafe.redocly.com", clientHeader: "redocly-client-generator" }); export const { configure, use } = client; -export const listMenuItems = (params: { - /** - * Case-insensitive substring match on item names. - */ - search?: string; - /** - * Number of results per page. - * @minimum 1 - * @maximum 100 - */ - limit?: number; -} = {}, init?: I): Promise, I>> => client.listMenuItems({ params }, init) as Promise, I>>; -export const getMenuItemPhoto = (menuItemId: string, params: { - /** - * Photo size to retrieve. - */ - photoSize?: "thumbnail" | "medium" | "large"; -} = {}, init?: I): Promise, I>> => client.getMenuItemPhoto({ menuItemId, params }, init) as Promise, I>>; -export const registerOAuth2Client = (body: RegisterClientRequest, init?: I): Promise, I>> => client.registerOAuth2Client({ body }, init) as Promise, I>>; +export const { listMenuItems, getMenuItemPhoto, registerOAuth2Client } = client; diff --git a/tests/e2e/generate-client/examples/zero-install-quickstart/src/main.ts b/tests/e2e/generate-client/examples/zero-install-quickstart/src/main.ts index 54eb670ea4..e55fc036a2 100644 --- a/tests/e2e/generate-client/examples/zero-install-quickstart/src/main.ts +++ b/tests/e2e/generate-client/examples/zero-install-quickstart/src/main.ts @@ -5,14 +5,17 @@ // library to install or keep in sync. Import the generated functions and call. import { getMenuItemPhoto, listMenuItems } from './api/client.js'; -const menu = await listMenuItems({ limit: 3 }); +const menu = await listMenuItems({ query: { limit: 3 } }); for (const item of menu.items) { console.log(`${item.name} — $${(item.price / 100).toFixed(2)}`); } const [first] = menu.items; if (first) { - const photo = await getMenuItemPhoto(first.id, { photoSize: 'thumbnail' }); + const photo = await getMenuItemPhoto({ + path: { menuItemId: first.id }, + query: { photoSize: 'thumbnail' }, + }); console.log( photo instanceof Blob ? `${first.name} photo: ${photo.type}, ${photo.size} bytes` : photo ); diff --git a/tests/e2e/generate-client/extension.test.ts b/tests/e2e/generate-client/extension.test.ts index 7a32f992b1..54bf15f55e 100644 --- a/tests/e2e/generate-client/extension.test.ts +++ b/tests/e2e/generate-client/extension.test.ts @@ -69,7 +69,7 @@ describe('extension contract — flat surface (configure)', () => { }); try { - await getPetById(1); + await getPetById({ path: { id: 1 } }); console.log(JSON.stringify({ threw: false })); } catch (e) { console.log(JSON.stringify({ threw: true, name: (e as Error).constructor.name, message: (e as Error).message })); diff --git a/tests/e2e/generate-client/identifier-injection.test.ts b/tests/e2e/generate-client/identifier-injection.test.ts index e93697bc2c..904c3422e8 100644 --- a/tests/e2e/generate-client/identifier-injection.test.ts +++ b/tests/e2e/generate-client/identifier-injection.test.ts @@ -72,12 +72,12 @@ describe('generate-client identifier / comment injection', () => { // No payload survives as a top-level statement (only inside identifiers/comments). expect(src).not.toMatch(/^\s*globalThis\.PWNED/m); // The operation name became a single valid identifier (no parens, spaces, or - // semicolons), and the flat sugar forwards to the client method of that same name. - const flat = src.match( - /export const ([A-Za-z_$][A-Za-z0-9_$]*) = [^\n]*=> client\.([A-Za-z_$][A-Za-z0-9_$]*)\(/ - ); - expect(flat, 'no flat call sugar found in the generated client').not.toBeNull(); - expect(flat![1]).toBe(flat![2]); + // semicolons), and it is exported by destructuring the client under that same name. + const bindings = src.match(/export const \{ ([^}]*) \} = client;\s*$/m); + expect(bindings, 'no operation bindings found in the generated client').not.toBeNull(); + for (const name of bindings![1].split(', ')) { + expect(name).toMatch(/^[A-Za-z_$][A-Za-z0-9_$]*$/); + } // Strongest proof: the whole file type-checks. Injected statements would not. const tsc = spawnSync( diff --git a/tests/e2e/generate-client/middleware.test.ts b/tests/e2e/generate-client/middleware.test.ts index efcf88d897..a89c081d39 100644 --- a/tests/e2e/generate-client/middleware.test.ts +++ b/tests/e2e/generate-client/middleware.test.ts @@ -69,7 +69,7 @@ describe('middleware — flat surface (use)', () => { { onError: (e) => new Error('second:' + e.message) }, ); try { - await getPetById(1); + await getPetById({ path: { id: 1 } }); console.log(JSON.stringify({ threw: false })); } catch (e) { console.log(JSON.stringify({ threw: true, message: (e as Error).message })); @@ -128,7 +128,7 @@ describe('middleware — flat surface (use)', () => { let op: unknown; configure({ fetch: (async () => ${OK}) as unknown as typeof fetch }); use({ onRequest: (ctx) => { op = ctx.operation; } }); - await createPet({ name: 'Rex' }); + await createPet({ body: { name: 'Rex' } }); console.log(JSON.stringify({ op })); ` ) as { op: { id: string; path: string; tags: string[] } }; @@ -149,7 +149,7 @@ describe('middleware — flat surface (use)', () => { fetch: (async (_url: string, init: RequestInit) => { sent = init.body as string; return ${OK}; }) as unknown as typeof fetch, }); use({ onRequest: (ctx) => { (ctx.body as { name: string }).name = 'Mutated'; } }); - await createPet({ name: 'Rex' }); + await createPet({ body: { name: 'Rex' } }); console.log(JSON.stringify({ sent })); ` ) as { sent: string }; @@ -210,7 +210,7 @@ describe('middleware — result error mode', () => { onResponse: () => { ran.push('res'); }, onError: () => { ran.push('err'); return new Error('should-not-run'); }, }); - const r = await getPetById(1) as { error: unknown; data: unknown }; + const r = await getPetById({ path: { id: 1 } }) as { error: unknown; data: unknown }; console.log(JSON.stringify({ ran, hasError: r.error !== undefined, hasData: r.data !== undefined })); ` ) as { ran: string[]; hasError: boolean; hasData: boolean }; diff --git a/tests/e2e/generate-client/mock.test.ts b/tests/e2e/generate-client/mock.test.ts index 83daf0eeb0..28c37caddc 100644 --- a/tests/e2e/generate-client/mock.test.ts +++ b/tests/e2e/generate-client/mock.test.ts @@ -51,7 +51,7 @@ describe('mock generator — generated client through MSW', () => { server.listen({ onUnhandledRequest: 'error' }); configure({ serverUrl: 'https://api.example.com' }); try { - const pet = await getPetById(1); + const pet = await getPetById({ path: { id: 1 } }); process.stdout.write(JSON.stringify({ ok: pet !== undefined, id: pet.id, name: pet.name })); } finally { server.close(); diff --git a/tests/e2e/generate-client/multipart.test.ts b/tests/e2e/generate-client/multipart.test.ts index 1dd24bbcee..3e2a511327 100644 --- a/tests/e2e/generate-client/multipart.test.ts +++ b/tests/e2e/generate-client/multipart.test.ts @@ -66,7 +66,7 @@ describe('generate-client typed multipart body (#5)', () => { }); const file = new Blob(['hello'], { type: 'text/plain' }); - await upload({ file, orgId: 'org_1', tags: ['a', 'b'], meta: { k: 'v' } }); + await upload({ body: { file, orgId: 'org_1', tags: ['a', 'b'], meta: { k: 'v' } } }); const fd = body as FormData; console.log(JSON.stringify({ @@ -112,7 +112,7 @@ describe('generate-client typed multipart body (#5)', () => { use({ onRequest: (ctx) => { (ctx.body as { orgId: string }).orgId = 'mutated'; } }); const file = new Blob(['hi'], { type: 'text/plain' }); - await upload({ file, orgId: 'org_1' }); + await upload({ body: { file, orgId: 'org_1' } }); const fd = body as FormData; console.log(JSON.stringify({ isFormData: fd instanceof FormData, orgId: fd.get('orgId') })); diff --git a/tests/e2e/generate-client/package-runtime-consumer/index.ts b/tests/e2e/generate-client/package-runtime-consumer/index.ts index 900f078cda..24cc14f26b 100644 --- a/tests/e2e/generate-client/package-runtime-consumer/index.ts +++ b/tests/e2e/generate-client/package-runtime-consumer/index.ts @@ -10,10 +10,10 @@ async function main(): Promise { client.auth.bearer('test-token'); // Flat sugar: positional path value forwarded under the wire name `order-id`. - const order = await getOrder('o-1', { expand: 'items' }); + const order = await getOrder({ path: { 'order-id': 'o-1' }, query: { expand: 'items' } }); // Grouped instance call: the caller uses the wire-name key directly. - const grouped = await client.getOrder({ 'order-id': 'o-2' }); - const created = await createOrder({ status: 'open' }); + const grouped = await client.getOrder({ path: { 'order-id': 'o-2' } }); + const created = await createOrder({ body: { status: 'open' } }); // The op whose id collides with the reserved `configure` member — renamed sugar, // while middleware still sees the SPEC operationId. const collided = await configure_2(); diff --git a/tests/e2e/generate-client/pagination-consumer/index-abort.ts b/tests/e2e/generate-client/pagination-consumer/index-abort.ts index 4b9df83acb..9b2c51f770 100644 --- a/tests/e2e/generate-client/pagination-consumer/index-abort.ts +++ b/tests/e2e/generate-client/pagination-consumer/index-abort.ts @@ -9,7 +9,10 @@ async function main(): Promise { let error: string | null = null; try { - for await (const order of listOrders.items({ limit: 2 }, { signal: controller.signal })) { + for await (const order of listOrders.items( + { query: { limit: 2 } }, + { signal: controller.signal } + )) { void order; received++; if (received === 1) { diff --git a/tests/e2e/generate-client/pagination-consumer/index-offset.ts b/tests/e2e/generate-client/pagination-consumer/index-offset.ts index 0b19996174..3fb9699b11 100644 --- a/tests/e2e/generate-client/pagination-consumer/index-offset.ts +++ b/tests/e2e/generate-client/pagination-consumer/index-offset.ts @@ -5,13 +5,13 @@ import { listMenuItems, OPERATIONS } from './api-offset.js'; // each page's item count until an empty page arrives. async function main(): Promise { const names: string[] = []; - for await (const item of listMenuItems.items({ limit: 2 })) { + for await (const item of listMenuItems.items({ query: { limit: 2 } })) { names.push(item.name); // compile-time: `item` is `MenuItem` } // The trailing empty page IS yielded (every page arrives before the stop check). const pageSizes: number[] = []; - for await (const page of listMenuItems.pages({ limit: 2 })) { + for await (const page of listMenuItems.pages({ query: { limit: 2 } })) { pageSizes.push(page.items.length); } diff --git a/tests/e2e/generate-client/pagination-consumer/index-package.ts b/tests/e2e/generate-client/pagination-consumer/index-package.ts index a3d63542da..bee2fe501f 100644 --- a/tests/e2e/generate-client/pagination-consumer/index-package.ts +++ b/tests/e2e/generate-client/pagination-consumer/index-package.ts @@ -5,7 +5,7 @@ import { listOrders } from './api-package.js'; // package — one full `.items()` walk proves the capability is wired there too. async function main(): Promise { const ids: string[] = []; - for await (const order of listOrders.items({ limit: 2 })) { + for await (const order of listOrders.items({ query: { limit: 2 } })) { ids.push(order.id); } diff --git a/tests/e2e/generate-client/pagination-consumer/index.ts b/tests/e2e/generate-client/pagination-consumer/index.ts index 2231d97eed..fdb233bee2 100644 --- a/tests/e2e/generate-client/pagination-consumer/index.ts +++ b/tests/e2e/generate-client/pagination-consumer/index.ts @@ -4,30 +4,29 @@ import { listOrders } from './api.js'; // Exercises `.items()` across three cursor pages, `.pages()` page-level access, and // resume from a caller-provided cursor — while the caller's args are never mutated. async function main(): Promise { - // `.items()`: the iterators take the SAME flat arguments as the call itself — the - // query params object, not a grouped `{ params }`. Every request forwards the - // caller's `limit` alongside the advancing cursor. - const firstArgs = { limit: 2 }; + // `.items()` takes the same input as the call itself, because it IS the same function's + // member. Every request forwards the caller's `limit` alongside the advancing cursor. + const firstArgs = { query: { limit: 2 } }; const ids: string[] = []; for await (const order of listOrders.items(firstArgs)) { ids.push(order.id); // compile-time: `order` is `Order` } - // The iterator clones params per request — the cursor never leaks into caller args. - const firstCursorLeaked = 'cursor' in firstArgs; + // The iterator clones the query bag per request — the cursor never leaks into caller args. + const firstCursorLeaked = 'cursor' in firstArgs.query; // `.pages()`: whole pages, typed as the raw response — sizes pin the 2+2+1 layout. const pageSizes: number[] = []; - for await (const page of listOrders.pages({ limit: 2 })) { + for await (const page of listOrders.pages({ query: { limit: 2 } })) { pageSizes.push(page.orders.length); } // Resume: a caller-provided initial cursor starts iteration at that page. - const resumeArgs = { cursor: 'c2', limit: 2 }; + const resumeArgs = { query: { cursor: 'c2', limit: 2 } }; const resumedIds: string[] = []; for await (const order of listOrders.items(resumeArgs)) { resumedIds.push(order.id); } - const resumeCursorAfter = resumeArgs.cursor; + const resumeCursorAfter = resumeArgs.query.cursor; process.stdout.write( JSON.stringify({ ids, firstCursorLeaked, pageSizes, resumedIds, resumeCursorAfter }) + '\n' diff --git a/tests/e2e/generate-client/pagination.test.ts b/tests/e2e/generate-client/pagination.test.ts index 6d857082c1..267ee28248 100644 --- a/tests/e2e/generate-client/pagination.test.ts +++ b/tests/e2e/generate-client/pagination.test.ts @@ -104,18 +104,10 @@ describe('generate-client pagination consumer', () => { expect(api).toContain( 'getOrder: { id: "getOrder", method: "GET", path: "/orders/{orderId}", params: [{ name: "orderId", in: "path" }] }' ); - // …and the flat sugar attaches `.pages`/`.items` that take the SAME flat arguments - // as the call, so one exported function never has two argument shapes. - expect(api).toContain( - 'export const listOrders = Object.assign((params: {' - ); - expect(api).toContain( - 'init: RequestOptions = {}) => client.listOrders.pages({ params }, init)' - ); - expect(api).toContain( - 'init: RequestOptions = {}) => client.listOrders.items({ params }, init)' - ); - expect(api).not.toContain('client.listMenuItems.pages'); + // …and the exported name is the client method itself, so `.pages`/`.items` ride along + // with the same input shape as the call. No wrapper, no second argument shape. + expect(api).toContain('export const { listOrders, listMenuItems, getOrder } = client;'); + expect(api).not.toContain('export const listOrders = Object.assign'); // Inline mode embeds paginate.ts (the infinite-loop guard is its fingerprint). expect(api).toContain('// ─── Embedded runtime'); expect(api).toContain('Pagination did not advance'); @@ -126,9 +118,7 @@ describe('generate-client pagination consumer', () => { 'listMenuItems: { id: "listMenuItems", method: "GET", path: "/menu", params: [{ name: "offset", in: "query" }, { name: "limit", in: "query" }], pagination: { style: "offset", param: "offset", limitParam: "limit", items: "/items" } }' ); expect(offset).toContain('item: MenuItem;'); - expect(offset).toContain( - 'init: RequestOptions = {}) => client.listMenuItems.pages({ params }, init)' - ); + expect(offset).toContain('export const { listOrders, listMenuItems, getOrder } = client;'); // …precedence keeps the extension's cursor rule on listOrders (not the convention)… expect(offset).toContain( 'pagination: { style: "cursor", param: "cursor", limitParam: "limit", nextCursor: "/nextCursor", items: "/orders" }' @@ -145,9 +135,7 @@ describe('generate-client pagination consumer', () => { expect(pkg).toContain( 'pagination: { style: "cursor", param: "cursor", limitParam: "limit", nextCursor: "/nextCursor", items: "/orders" }' ); - expect(pkg).toContain( - 'init: RequestOptions = {}) => client.listOrders.pages({ params }, init)' - ); + expect(pkg).toContain('export const { listOrders, listMenuItems, getOrder } = client;'); }, 60_000); test('typecheck gate: all three generated clients + consumer scripts, strict', () => { diff --git a/tests/e2e/generate-client/parse-as.test.ts b/tests/e2e/generate-client/parse-as.test.ts index dddaa793bd..74f8d62a73 100644 --- a/tests/e2e/generate-client/parse-as.test.ts +++ b/tests/e2e/generate-client/parse-as.test.ts @@ -45,15 +45,15 @@ describe('generate-client parseAs', () => { "import { getGiftcardsCardId } from './client.js';", '', 'export async function streamUsage() {', - " return getGiftcardsCardId({ parseAs: 'stream' });", + " return getGiftcardsCardId({ path: { cardId: 'gc_1' } }, { parseAs: 'stream' });", '}', '', 'export async function textUsage() {', - " return getGiftcardsCardId({ parseAs: 'text' });", + " return getGiftcardsCardId({ path: { cardId: 'gc_1' } }, { parseAs: 'text' });", '}', '', '// @ts-expect-error — parseAs is a closed union; bogus kinds are rejected.', - "export const bogus = getGiftcardsCardId({ parseAs: 'xml' });", + "export const bogus = getGiftcardsCardId({ path: { cardId: 'gc_1' } }, { parseAs: 'xml' });", '', ].join('\n'), 'utf-8' diff --git a/tests/e2e/generate-client/path-param-idents.test.ts b/tests/e2e/generate-client/path-param-idents.test.ts index 20ccae5e9f..4f82c0eb75 100644 --- a/tests/e2e/generate-client/path-param-idents.test.ts +++ b/tests/e2e/generate-client/path-param-idents.test.ts @@ -93,20 +93,16 @@ describe('non-identifier path parameters', () => { if (dir && existsSync(dir)) rmSync(dir, { recursive: true, force: true }); }); - test('emits safe argument names routed back under the wire name', () => { + test('keys the path layer by the wire name, quoting what needs it', () => { const client = readFileSync(join(dir, 'client.ts'), 'utf-8'); - // `widget-id` → safe `widget_id` argument, routed under the quoted wire key. - expect(client).toContain( - 'export const getWidget = (widget_id: string, init?: I)' - ); - expect(client).toContain('client.getWidget({ "widget-id": widget_id }, init)'); + // The wire name IS the key, so no binding identifier is derived and nothing to remap. + expect(client).toContain('export type GetWidgetPath = {\n "widget-id": string;\n};'); // The descriptor keeps the WIRE name for URL substitution. expect(client).toContain('params: [{ name: "widget-id", in: "path" }]'); - // reserved word `new` → `_new` argument, routed under the `new` key. - expect(client).toContain( - 'export const getItem = (_new: string, init?: I)' - ); - expect(client).toContain('client.getItem({ new: _new }, init)'); + // A reserved word is a fine object key, quoted or not. + // A reserved word is quoted as a key, which is what makes it usable as one. + expect(client).toContain('export type GetItemPath = {\n "new": string;\n};'); + expect(client).not.toContain('_new'); }); test('the generated client type-checks under strict mode', () => { @@ -130,8 +126,8 @@ describe('non-identifier path parameters', () => { }) as unknown as typeof fetch, }); - await getWidget('abc'); - await getItem('xyz'); + await getWidget({ path: { 'widget-id': 'abc' } }); + await getItem({ path: { new: 'xyz' } }); console.log(JSON.stringify(urls)); `; const urls = runConsumer(dir, consumer) as string[]; diff --git a/tests/e2e/generate-client/query-styles.test.ts b/tests/e2e/generate-client/query-styles.test.ts index 054d053d47..661a62e0a9 100644 --- a/tests/e2e/generate-client/query-styles.test.ts +++ b/tests/e2e/generate-client/query-styles.test.ts @@ -69,7 +69,7 @@ describe('generate-client query serialization styles', () => { ` return new Response('{"results":[]}', { status: 200, headers: { 'content-type': 'application/json' } });`, ` },`, `});`, - `await search({ tags: ['a', 'b'], q: ['x', 'y'], ids: ['1', '2'], filter: 'a/b', limit: 5 });`, + `await search({ query: { tags: ['a', 'b'], q: ['x', 'y'], ids: ['1', '2'], filter: 'a/b', limit: 5 } });`, `process.stdout.write(captured);`, ``, ].join('\n'), diff --git a/tests/e2e/generate-client/redocly-config.test.ts b/tests/e2e/generate-client/redocly-config.test.ts index fbb7521bf7..ec62077ed1 100644 --- a/tests/e2e/generate-client/redocly-config.test.ts +++ b/tests/e2e/generate-client/redocly-config.test.ts @@ -302,8 +302,8 @@ describe('generate-client redocly.yaml config', () => { const out = readFileSync(join(dir, 'out.ts'), 'utf-8'); // The convention fits the cursor-style list operations -> descriptor pagination… expect(out).toContain('pagination: {'); - // …and the flat sugar preserves the method-attached iterators. - expect(out).toContain('=> client.listOrders.items({ params }, init)'); + // …and the exported binding IS the method, so `.items()` rides along with it. + expect(out).toContain('listOrders, '); rmSync(dir, { recursive: true, force: true }); }, 60_000); diff --git a/tests/e2e/generate-client/retry.test.ts b/tests/e2e/generate-client/retry.test.ts index b4c9eb87e0..34e4137165 100644 --- a/tests/e2e/generate-client/retry.test.ts +++ b/tests/e2e/generate-client/retry.test.ts @@ -109,13 +109,13 @@ describe('retry behavior', () => { // default predicate: POST is not idempotent → no retry. calls = 0; configure({ fetch: failing, retry: { retries: 3, retryDelay: 1 } }); - try { await createPet({ name: 'x' } as any); } catch {} + try { await createPet({ body: { name: 'x' } } as any); } catch {} const defaultCalls = calls; // retryOn: () => true → POST retried. calls = 0; configure({ fetch: failing, retry: { retries: 3, retryDelay: 1, retryOn: () => true } }); - try { await createPet({ name: 'x' } as any); } catch {} + try { await createPet({ body: { name: 'x' } } as any); } catch {} const optInCalls = calls; console.log(JSON.stringify({ defaultCalls, optInCalls })); diff --git a/tests/e2e/generate-client/spec-versions.test.ts b/tests/e2e/generate-client/spec-versions.test.ts index e796427e48..bd254ddaa2 100644 --- a/tests/e2e/generate-client/spec-versions.test.ts +++ b/tests/e2e/generate-client/spec-versions.test.ts @@ -21,15 +21,14 @@ function generateAndTypecheck(fixture: string): { generated: string } { describe('generate-client spec versions', () => { it('generates a type-checking client from a Swagger 2.0 document', () => { const { generated } = generateAndTypecheck('swagger2.yaml'); - expect(generated).toContain('export const getPet = { const { generated } = generateAndTypecheck('oas3.2.yaml'); - expect(generated).toContain('export const getThing = { it('synthesizes operation names from method+path when operationId is omitted', () => { const { generated } = generateAndTypecheck('no-operationid.yaml'); - expect(generated).toContain('export const getGiftcardsCardId = { let error: string | null = null; try { - for await (const ev of streamAbort({ signal: controller.signal })) { + for await (const ev of streamAbort({}, { signal: controller.signal })) { void ev; received++; // Abort mid-stream, after the first event, while the server holds open. diff --git a/tests/e2e/generate-client/sse-consumer/index-connect-retry.ts b/tests/e2e/generate-client/sse-consumer/index-connect-retry.ts index 9e4542f47b..61bd40f584 100644 --- a/tests/e2e/generate-client/sse-consumer/index-connect-retry.ts +++ b/tests/e2e/generate-client/sse-consumer/index-connect-retry.ts @@ -22,7 +22,7 @@ configure({ async function main(): Promise { const events: string[] = []; // Tiny reconnect backoff so the test doesn't wait on the 1s default. - for await (const ev of streamMessages({ reconnectDelay: 1 })) { + for await (const ev of streamMessages({}, { reconnectDelay: 1 })) { events.push(ev.data.text); } process.stdout.write(JSON.stringify({ calls, events, finished: true }) + '\n'); diff --git a/tests/e2e/generate-client/sse.test.ts b/tests/e2e/generate-client/sse.test.ts index 367dfa2a2d..03e4d5144d 100644 --- a/tests/e2e/generate-client/sse.test.ts +++ b/tests/e2e/generate-client/sse.test.ts @@ -56,9 +56,9 @@ describe('generate-client SSE', () => { 'streamTicks: { id: "streamTicks", method: "GET", path: "/ticks", tags: ["Ticks"], responseKind: "sse", sseDataKind: "text" }' ); expect(generated).toMatch(/streamTicks: \{\s*args: \{\};\s*result: string;\s*kind: "sse";/); - // Flat call sugar: an SSE op is a top-level export returning the async generator. + // The binding is the client's own method, which returns the async generator. expect(generated).toContain( - 'export const streamMessages = (init: SseOptions = {}) => client.streamMessages({}, init);' + 'export const { getHealth, streamMessages, streamAbort, streamTicks } = client;' ); // A type-usage snippet proving `ServerSentEvent.data.text` is typed @@ -69,7 +69,7 @@ describe('generate-client SSE', () => { `import { streamMessages, configure } from './client.js';`, `async function check() {`, ` for await (const ev of streamMessages()) { const t: string = ev.data.text; void t; const id: string | undefined = ev.id; void id; }`, - ` const it = streamMessages({ reconnect: false, reconnectDelay: 500 });`, + ` const it = streamMessages({}, { reconnect: false, reconnectDelay: 500 });`, ` void it;`, `}`, `void check; void configure;`, @@ -90,7 +90,9 @@ describe('generate-client SSE', () => { const entrySrc = readFileSync(entry, 'utf-8'); expect(entrySrc).toContain('async function* sse('); - expect(entrySrc).toContain('export const streamMessages = (init: SseOptions = {})'); + expect(entrySrc).toContain( + 'export const { getHealth, streamMessages, streamAbort, streamTicks } = client;' + ); const files = collectTsFiles(dir); expect(files.map((f) => f.split('/').pop()).sort()).toEqual(['client.schemas.ts', 'client.ts']); diff --git a/tests/e2e/generate-client/tanstack-query.runtime.test.ts b/tests/e2e/generate-client/tanstack-query.runtime.test.ts index c1f4f52c6c..7d19eaec0a 100644 --- a/tests/e2e/generate-client/tanstack-query.runtime.test.ts +++ b/tests/e2e/generate-client/tanstack-query.runtime.test.ts @@ -78,7 +78,7 @@ describe('generate-client tanstack-query runtime (React hooks, jsdom)', () => { }, }); - const { result } = renderHook(() => useQuery(mod.getPetByIdOptions({ id: 1 })), { + const { result } = renderHook(() => useQuery(mod.getPetByIdOptions({ path: { id: 1 } })), { wrapper: wrapper(newClient()), }); diff --git a/tests/e2e/generate-client/tanstack-query.test.ts b/tests/e2e/generate-client/tanstack-query.test.ts index a8c042e2fa..e489861087 100644 --- a/tests/e2e/generate-client/tanstack-query.test.ts +++ b/tests/e2e/generate-client/tanstack-query.test.ts @@ -52,13 +52,13 @@ describe('generate-client tanstack-query generator', () => { "import { createPetMutation, getPetByIdOptions, listPetsOptions } from './client.tanstack.js';", "import type { Pet } from './client.js';", 'export function useGetPet(id: number) {', - ' const query = useQuery(getPetByIdOptions({ id }));', + ' const query = useQuery(getPetByIdOptions({ path: { id } }));', ' // Wrapper inits exclude `envelope`: cached data is the plain body, never an envelope.', ' const pet: Pet | undefined = query.data;', ' return pet;', '}', 'export function useListPets() {', - " return useQuery(listPetsOptions({ params: { filter: { name: 'rex' } } }));", + " return useQuery(listPetsOptions({ query: { filter: { name: 'rex' } } }));", '}', 'export function useCreatePet() {', ' return useMutation(createPetMutation());', @@ -127,10 +127,10 @@ describe('generate-client tanstack-query generator', () => { "import { useMutation, useQuery } from '@tanstack/react-query';", "import { createPetMutation, getPetByIdOptions, listPetsOptions } from './client.tanstack.js';", 'export function useGetPet(id: number) {', - ' return useQuery(getPetByIdOptions({ id }));', + ' return useQuery(getPetByIdOptions({ path: { id } }));', '}', 'export function useListPets() {', - " return useQuery(listPetsOptions({ params: { filter: { name: 'rex' } } }));", + " return useQuery(listPetsOptions({ query: { filter: { name: 'rex' } } }));", '}', 'export function useCreatePet() {', ' return useMutation(createPetMutation());', From 57f009dac098d58cd9a47e01d0d30b110d1bc479 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 19 Aug 2026 16:48:18 +0300 Subject: [PATCH 204/211] fix(client-generator): keep a repeated parameter name usable in every language MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenAPI lets one operation use the same parameter name in two locations, and the Python, PHP, and Go clients each pass one argument per parameter. Their generated modules did not parse at all for such a description: `id` in the path and in the query produced `def get_thing(self, id, *, id=None)` (SyntaxError), a redefined `$id` (PHP fatal), and a duplicate `body` argument in Go. The same break came from a parameter named after an argument the method declares itself — `body`, `headers`, `timeout`, `params`, `ctx`. Parameter names are now derived through one namespace per signature, seeded with those argument slots, so the later name moves aside the way each language spells names: `id_2`, `$id2`, `id2`. The wire name is untouched, so both values still reach the API as written, and the pipeline reports the collision once so the publisher can rename it in the description instead. The rule lives in the authoring toolkit as `uniqueIdentifiers`, beside `identifierFor`, so a generator written by someone else inherits it. Each language skill records it, and the reference guide documents the rename. Each language's e2e suite now generates from a fixture built out of these names and proves the result is real code: `py_compile`, `php -l`, and `go build`. --- .changeset/agent-friendly-generators.md | 2 + docs/@v2/guides/use-generated-client.md | 7 ++ .../client-generator/eject-assets/AGENTS.md | 31 ++++---- .../skills/client-generators/SKILL.md | 31 ++++---- .../eject-assets/skills/go-generator/SKILL.md | 5 ++ .../skills/php-generator/SKILL.md | 5 ++ .../skills/python-generator/SKILL.md | 5 ++ .../src/__tests__/index.test.ts | 32 ++++++++ .../src/authoring/__tests__/naming.test.ts | 29 +++++++- .../client-generator/src/authoring/index.ts | 3 +- .../client-generator/src/authoring/naming.ts | 31 ++++++++ .../src/generators/go/AGENTS.md | 5 ++ .../src/generators/go/index.ts | 40 +++++++--- .../src/generators/java/AGENTS.md | 5 ++ .../src/generators/php/AGENTS.md | 5 ++ .../src/generators/php/index.ts | 23 +++++- .../src/generators/python/AGENTS.md | 5 ++ .../src/generators/python/index.ts | 33 ++++++--- packages/client-generator/src/pipeline.ts | 33 +++++++++ .../fixtures/repeated-params.yaml | 74 +++++++++++++++++++ tests/e2e/generate-client/go.test.ts | 39 +++++++++- tests/e2e/generate-client/php.test.ts | 34 ++++++++- tests/e2e/generate-client/python.test.ts | 35 +++++++++ 23 files changed, 454 insertions(+), 58 deletions(-) create mode 100644 tests/e2e/generate-client/fixtures/repeated-params.yaml diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index fb9374ed0e..bbc6545d34 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -13,6 +13,8 @@ Added language-neutral authoring toolkit with per-generator options, including ` Added an `eject-generator` command that vendors any built-in generator, with its design as an agent skill, into your repo. +Fixed generated Python, PHP, and Go clients for descriptions that use one parameter name in two locations (`id` in the path and in the query, which OpenAPI permits), or a parameter named after an argument the method declares itself (`body`, `headers`, `timeout`, `params`). The later parameter now takes a suffixed name — `id_2` in Python, `$id2` in PHP, `id2` in Go — and the wire names stay as written, so both values still reach the API. Before this, the generated module did not parse at all: a `SyntaxError` in Python, a fatal redefinition in PHP, and a compile error in Go. + Renamed pagination operation extension from `x-redocly-pagination` to `x-redoclyPagination`. The previous name is no longer read. diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index 3db812e9c3..046347a1e3 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -361,6 +361,13 @@ The wire name does not change. For example, `tag.type_` in Python, `$tag->type_` in PHP, and `tag.Type_` in Go all serialize as `type`. The same applies to method arguments: `list_tags(type_=...)`, `ListTagsParams{Type_: ...}`. +OpenAPI lets one operation use the same parameter name in two locations, such as `id` in the path and `id` in the query. +The SDKs whose methods take one argument per parameter cannot declare that name twice, so the later parameter gets a suffix: `id_2` in Python, `$id2` in PHP, `id2` in Go. +A parameter named after an argument the method declares itself, such as `body` or `headers`, moves aside the same way. +The wire names never change, so both values reach the API as written, and the generator reports each rename. +To choose the names yourself, rename the parameter in the description. +The TypeScript client needs no rename, because each layer of its input is a separate object. + The generator resolves type and method **names** once, in the shared model. It checks them against a reserved set that is the union across the supported languages. Because of this, a schema keeps the same name in every SDK that you generate from the description. diff --git a/packages/client-generator/eject-assets/AGENTS.md b/packages/client-generator/eject-assets/AGENTS.md index 208ccebeb4..fd8b937dba 100644 --- a/packages/client-generator/eject-assets/AGENTS.md +++ b/packages/client-generator/eject-assets/AGENTS.md @@ -91,21 +91,22 @@ 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). | -| `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. | +| `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 ef717aabf5..3a0250145a 100644 --- a/packages/client-generator/eject-assets/skills/client-generators/SKILL.md +++ b/packages/client-generator/eject-assets/skills/client-generators/SKILL.md @@ -96,21 +96,22 @@ 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). | -| `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. | +| `discriminatorCases(schema, model)` | `{ property, cases }` dispatch table for discriminated unions. | +| `isNullable(schema)` / `unwrapNullable(schema)` | Detect and strip `null` union members (`Optional[T]`, pointers, `Option`). | +| `enumValues(schema)` | Values plus SCREAMING_SNAKE member-name suggestions. | +| `headerCoerceType(schema, model)` | Response-header coerce hint (`integer`/`number`/`boolean`/`string`) through refs, nullables, and allOf wrappers. | +| `casing` / `identifierFor(name, { style, reserved })` | camel/pascal/snake/screaming; keyword-safe identifiers (`RESERVED_WORDS.python/go/typescript` shipped). | +| `uniqueIdentifiers(names, { style, reserved, taken })` | The same, made unique among themselves and among names you already took — for a signature that takes one argument per parameter. | +| `Printer` | Indentation-aware text builder — no manual whitespace bookkeeping. | +| `docText(description)` | Description as trimmed lines for any comment syntax. | +| `schemaAtPointer(schema, pointer, model)` | Resolve an RFC 6901 JSON pointer over a schema (through refs and allOf) — e.g. a pagination `items` pointer to its element type. | +| `paginationRuleFor(op, config)` | The pagination rule that applies to an operation (per-op config > extension > fitting convention), normalized. | +| `renderReferencePage(model, options)` | The Markdown reference page a generator's `docs` hook returns — your `sample` hook supplies its call snippets. | +| `NotSupportedError` | Throw it to reject an option the generator can't honor — the CLI prints the message as a user error, not a crash. | +| `AUTHORING_HELPER_NAMES` | The list of the above (introspection). | Worked example: the built-in `python` generator (`packages/client-generator/src/generators/python/index.ts` in the Redocly CLI repo) is diff --git a/packages/client-generator/eject-assets/skills/go-generator/SKILL.md b/packages/client-generator/eject-assets/skills/go-generator/SKILL.md index e6b701dd60..d461feb9e9 100644 --- a/packages/client-generator/eject-assets/skills/go-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/go-generator/SKILL.md @@ -28,6 +28,11 @@ runtime. Go ≥ 1.21, standard library only — zero dependencies. (never `// `, which gofmt strips), and CONSECUTIVE blank lines collapse to one — gofmt rewrites `//\n//` to a single `//`, so emitting both means our output is not gofmt-clean. Descriptions with a double blank line are common in real specs. +- **Every parameter is its own argument, so their names share one namespace** with the + arguments the method declares itself (`ctx`, `body`, `params`, and the receiver). Build them with + `uniqueIdentifiers(..., { taken: … })`: OpenAPI lets one operation use a name in two + locations (`id` in the path AND in the query), and Go rejects a duplicate parameter. The + wire name is untouched, so the request is unchanged. - **Naming:** exported PascalCase via `identifierFor` + an `N` prefix for digit-leading names (`3ds` → `N3ds` — an `_`-prefixed field is unexported and invisible to `encoding/json`); `+1`/`-1` become `Plus1`/`Minus1`. 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 8863cb1fd7..acfed7323e 100644 --- a/packages/client-generator/eject-assets/skills/php-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/php-generator/SKILL.md @@ -28,6 +28,11 @@ extension — zero Composer dependencies. The namespace derives from the API tit - The `Client` class is NOT `final` — PHP test suites mock concrete classes (`createMock(Client::class)`), and `final` would force a wrapper interface on every consumer. Model classes stay `final`. +- **Every parameter is its own argument, so their names share one namespace** with the + arguments the method declares itself (`$body`, `$headers`, `$idempotencyKey`). Build them with + `uniqueIdentifiers(..., { taken: … })`: OpenAPI lets one operation use a name in two + locations (`id` in the path AND in the query), and PHP rejects a redefined parameter outright. The + wire name is untouched, so the request is unchanged. - **Naming:** classes PascalCase, properties/methods camelCase via `identifierFor(..., RESERVED_WORDS.php)`; reserved words get a trailing underscore. - **Enums** are native backed enums (string/int); other scalars stay aliases. 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 735a81ace4..29204fe6d9 100644 --- a/packages/client-generator/eject-assets/skills/python-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/python-generator/SKILL.md @@ -51,6 +51,11 @@ One self-contained `.py`: typed dataclass models, a sync `Client` and an a - **`models: pydantic` adds a dependency, and the header says so.** The default mode keeps httpx as the only requirement; the pydantic header asks for both. A mode that quietly needed a package the file never named would fail at import with nothing to act on. +- **Every parameter is its own argument, so their names share one namespace** with the + arguments the method declares itself (`body`, `headers`, `timeout`, `retry`, `idempotency_key`). Build them with + `uniqueIdentifiers(..., { taken: … })`: OpenAPI lets one operation use a name in two + locations (`id` in the path AND in the query), and a `def` that declared one name twice is a `SyntaxError`. The + wire name is untouched, so the request is unchanged. - **Naming:** fields/methods snake*case via `identifierFor(..., RESERVED_WORDS.python)`; reserved words get a trailing underscore (`class*`); `+1`/`-1`become`plus_1`/`minus_1`. - **Enums** are `class X(str, Enum)` with SCREAMING members; **unions** are `Union[...]` diff --git a/packages/client-generator/src/__tests__/index.test.ts b/packages/client-generator/src/__tests__/index.test.ts index 129c4366cb..ef8d047e10 100644 --- a/packages/client-generator/src/__tests__/index.test.ts +++ b/packages/client-generator/src/__tests__/index.test.ts @@ -1,3 +1,4 @@ +import { logger } from '@redocly/openapi-core'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -192,6 +193,37 @@ describe('generateClient — end-to-end orchestration', () => { await rm(workDir, { recursive: true, force: true }); }); + it('reports a parameter name used in two locations, which every SDK has to spell once', async () => { + const warn = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); + const api = join(workDir, 'repeated.yaml'); + await writeFile( + api, + outdent` + openapi: 3.1.0 + info: { title: Repeated, version: 1.0.0 } + paths: + /things/{id}: + get: + operationId: getThing + parameters: + - { name: id, in: path, required: true, schema: { type: string } } + - { name: id, in: query, required: false, schema: { type: integer } } + responses: + '200': + description: OK + content: + application/json: + schema: { type: object } + `, + 'utf-8' + ); + await generateClient({ api, output: join(workDir, 'client.ts') }); + expect(warn.mock.calls.map(([message]) => message).join('\n')).toContain( + 'operation "getThing" uses "id" in more than one parameter location' + ); + warn.mockRestore(); + }); + it('writes the generated file to disk and reports its size', async () => { const api = join(workDir, 'spec.yaml'); await writeFile( diff --git a/packages/client-generator/src/authoring/__tests__/naming.test.ts b/packages/client-generator/src/authoring/__tests__/naming.test.ts index 5f010d76fe..d037cd3e22 100644 --- a/packages/client-generator/src/authoring/__tests__/naming.test.ts +++ b/packages/client-generator/src/authoring/__tests__/naming.test.ts @@ -1,4 +1,4 @@ -import { casing, identifierFor, RESERVED_WORDS } from '../naming.js'; +import { casing, identifierFor, RESERVED_WORDS, uniqueIdentifiers } from '../naming.js'; describe('casing', () => { it('splits on delimiters and case boundaries, handling acronyms', () => { @@ -48,3 +48,30 @@ describe('identifierFor', () => { expect(identifierFor('echo', { style: 'camel', reserved: RESERVED_WORDS.php })).toBe('echo_'); }); }); + +describe('uniqueIdentifiers', () => { + it('separates a repeat the way the casing style spells names', () => { + // OpenAPI lets one name appear in two locations; a signature cannot declare it twice. + expect(uniqueIdentifiers(['id', 'id'], { style: 'snake' })).toEqual(['id', 'id_2']); + expect(uniqueIdentifiers(['id', 'id', 'id'], { style: 'camel' })).toEqual(['id', 'id2', 'id3']); + }); + + it('moves aside for a name the caller already took', () => { + expect( + uniqueIdentifiers(['body', 'timeout'], { style: 'snake', taken: ['self', 'body', 'timeout'] }) + ).toEqual(['body_2', 'timeout_2']); + }); + + it('applies the style and the reserved-word rule first', () => { + expect( + uniqueIdentifiers(['order-id', 'class'], { + style: 'snake', + reserved: RESERVED_WORDS.python, + }) + ).toEqual(['order_id', 'class_']); + }); + + it('keeps distinct names distinct, and needs no suffix when nothing clashes', () => { + expect(uniqueIdentifiers(['a', 'b'], { style: 'camel', taken: ['c'] })).toEqual(['a', 'b']); + }); +}); diff --git a/packages/client-generator/src/authoring/index.ts b/packages/client-generator/src/authoring/index.ts index 578a5cb57c..67b9e36a7e 100644 --- a/packages/client-generator/src/authoring/index.ts +++ b/packages/client-generator/src/authoring/index.ts @@ -8,7 +8,7 @@ export { NotSupportedError } from '../errors.js'; export { Printer } from './printer.js'; export type { DateType } from './options.js'; -export { casing, identifierFor, RESERVED_WORDS } from './naming.js'; +export { casing, identifierFor, RESERVED_WORDS, uniqueIdentifiers } from './naming.js'; export { paginationRuleFor, type NeutralPaginationRule } from './pagination.js'; // The Markdown reference page a generator's `docs` hook returns. Here rather than in the // emitters, so a generator ejected as source reaches it through the package like we do. @@ -33,6 +33,7 @@ export const AUTHORING_HELPER_NAMES = [ 'Printer', 'casing', 'identifierFor', + 'uniqueIdentifiers', 'RESERVED_WORDS', 'flattenAllOf', 'discriminatorCases', diff --git a/packages/client-generator/src/authoring/naming.ts b/packages/client-generator/src/authoring/naming.ts index d1d1b3edd7..0621d68182 100644 --- a/packages/client-generator/src/authoring/naming.ts +++ b/packages/client-generator/src/authoring/naming.ts @@ -87,3 +87,34 @@ export function identifierFor( const base = styled === '' ? '_' : /^[0-9]/.test(styled) ? `_${styled}` : styled; return options.reserved?.has(base.toLowerCase()) ? `${base}_` : base; } + +/** + * `identifierFor` over a list of wire names, made unique among themselves and among the + * names already `taken` — `id`, `id_2`, `id_3`, … A language that passes parameters as + * separate arguments needs this: OpenAPI lets one name appear in two locations (`id` in the + * path AND in the query), and a signature cannot declare that name twice. Seed `taken` with + * the argument slots the method itself declares (a body, a headers bag, a timeout), so a + * parameter named after one of them moves aside instead of shadowing it. + * + * The wire name is untouched: only the binding moves, so the request is unchanged. + */ +export function uniqueIdentifiers( + names: readonly string[], + options: { + style?: keyof typeof casing; + reserved?: ReadonlySet; + taken?: Iterable; + } = {} +): string[] { + const used = new Set(options.taken ?? []); + // The separator follows the casing style, so the result stays idiomatic: `order_id_2` in + // snake-case languages, `orderId2` where names run together. + const separator = options.style === 'snake' || options.style === 'screaming' ? '_' : ''; + return names.map((name) => { + const base = identifierFor(name, options); + let unique = base; + for (let suffix = 2; used.has(unique); suffix++) unique = `${base}${separator}${suffix}`; + used.add(unique); + return unique; + }); +} diff --git a/packages/client-generator/src/generators/go/AGENTS.md b/packages/client-generator/src/generators/go/AGENTS.md index 315a958017..a3b20e5784 100644 --- a/packages/client-generator/src/generators/go/AGENTS.md +++ b/packages/client-generator/src/generators/go/AGENTS.md @@ -27,6 +27,11 @@ runtime. Go ≥ 1.21, standard library only — zero dependencies. (never `// `, which gofmt strips), and CONSECUTIVE blank lines collapse to one — gofmt rewrites `//\n//` to a single `//`, so emitting both means our output is not gofmt-clean. Descriptions with a double blank line are common in real specs. +- **Every parameter is its own argument, so their names share one namespace** with the + arguments the method declares itself (`ctx`, `body`, `params`, and the receiver). Build them with + `uniqueIdentifiers(..., { taken: … })`: OpenAPI lets one operation use a name in two + locations (`id` in the path AND in the query), and Go rejects a duplicate parameter. The + wire name is untouched, so the request is unchanged. - **Naming:** exported PascalCase via `identifierFor` + an `N` prefix for digit-leading names (`3ds` → `N3ds` — an `_`-prefixed field is unexported and invisible to `encoding/json`); `+1`/`-1` become `Plus1`/`Minus1`. diff --git a/packages/client-generator/src/generators/go/index.ts b/packages/client-generator/src/generators/go/index.ts index c9f76764b0..c7653ec073 100644 --- a/packages/client-generator/src/generators/go/index.ts +++ b/packages/client-generator/src/generators/go/index.ts @@ -13,6 +13,7 @@ import { flattenAllOf, headerCoerceType, identifierFor, + uniqueIdentifiers, isNullable, NotSupportedError, paginationRuleFor, @@ -27,6 +28,7 @@ import { GO_RUNTIME_SOURCE } from '../../emitters/go-runtime-sources.js'; import type { ApiModel, OperationModel, + ParamModel, PropertyModel, SchemaModel, ServerModel, @@ -472,6 +474,32 @@ function goPaginationLiteral(rule: NeutralPaginationRule): string { 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, @@ -503,11 +531,7 @@ function writeGoMethod( model?: ApiModel, envelope = false ): void { - const pathArgs = op.pathParams.map((param) => ({ - param, - go: identifierFor(param.name, { style: 'camel', reserved: GO }), - type: goType(param.schema, dateType), - })); + const pathArgs = pathArguments(op, dateType); const hasParams = op.queryParams.length > 0; const success = successSchema(op); const returnType = success === undefined ? undefined : goType(success, dateType); @@ -704,11 +728,7 @@ function writeGoPaginationWrappers( pageType: string, itemType: string ): void { - const pathArgs = op.pathParams.map((param) => ({ - param, - go: identifierFor(param.name, { style: 'camel', reserved: GO }), - type: goType(param.schema, dateType), - })); + const pathArgs = pathArguments(op, dateType); const hasParams = op.queryParams.length > 0; const args = [ 'ctx context.Context', diff --git a/packages/client-generator/src/generators/java/AGENTS.md b/packages/client-generator/src/generators/java/AGENTS.md index f783232c9d..afa7fc2a3f 100644 --- a/packages/client-generator/src/generators/java/AGENTS.md +++ b/packages/client-generator/src/generators/java/AGENTS.md @@ -39,6 +39,11 @@ switch patterns), HTTP over `java.net.http.HttpClient` — part of the JDK since (`Integer`, not `int`). Hydration is compile-time generated per record — `static Order fromJson(Object json)` and `Object toJson()` over the runtime's JSON graph, mirroring PHP's `fromArray`/`toArray` (no reflection). Wire names inline. +- **Every parameter is its own argument, so their names share one namespace** with the + arguments the method declares itself (`body`, `headers`, `options`). Build them with + `uniqueIdentifiers(..., { taken: … })`: OpenAPI lets one operation use a name in two + locations (`id` in the path AND in the query), and Java rejects a duplicate parameter. The + wire name is untouched, so the request is unchanged. - **Naming:** classes PascalCase, fields/methods camelCase via `identifierFor(..., RESERVED_WORDS.java)` (the `java` reserved set is new toolkit work); `+1`/`-1` → `plus1`/`minus1`; digit-leading names get a letter prefix. diff --git a/packages/client-generator/src/generators/php/AGENTS.md b/packages/client-generator/src/generators/php/AGENTS.md index c9f589f494..84c69dc9d4 100644 --- a/packages/client-generator/src/generators/php/AGENTS.md +++ b/packages/client-generator/src/generators/php/AGENTS.md @@ -27,6 +27,11 @@ extension — zero Composer dependencies. The namespace derives from the API tit - The `Client` class is NOT `final` — PHP test suites mock concrete classes (`createMock(Client::class)`), and `final` would force a wrapper interface on every consumer. Model classes stay `final`. +- **Every parameter is its own argument, so their names share one namespace** with the + arguments the method declares itself (`$body`, `$headers`, `$idempotencyKey`). Build them with + `uniqueIdentifiers(..., { taken: … })`: OpenAPI lets one operation use a name in two + locations (`id` in the path AND in the query), and PHP rejects a redefined parameter outright. The + wire name is untouched, so the request is unchanged. - **Naming:** classes PascalCase, properties/methods camelCase via `identifierFor(..., RESERVED_WORDS.php)`; reserved words get a trailing underscore. - **Enums** are native backed enums (string/int); other scalars stay aliases. diff --git a/packages/client-generator/src/generators/php/index.ts b/packages/client-generator/src/generators/php/index.ts index d29b505379..f6273fb1e5 100644 --- a/packages/client-generator/src/generators/php/index.ts +++ b/packages/client-generator/src/generators/php/index.ts @@ -13,6 +13,7 @@ import { flattenAllOf, headerCoerceType, identifierFor, + uniqueIdentifiers, isNullable, paginationRuleFor, renderReferencePage, @@ -528,19 +529,33 @@ type MethodArgs = { 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 { - const pathArgs = op.pathParams.map((param) => ({ - php: propertyName(param.name), + // 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) => { - const php = propertyName(param.name); + const queryArgs = op.queryParams.map((param, index) => { + const php = names[op.pathParams.length + index]; return { php, wire: param.name, diff --git a/packages/client-generator/src/generators/python/AGENTS.md b/packages/client-generator/src/generators/python/AGENTS.md index 0f7b0a0cb8..308cdaa5cf 100644 --- a/packages/client-generator/src/generators/python/AGENTS.md +++ b/packages/client-generator/src/generators/python/AGENTS.md @@ -50,6 +50,11 @@ One self-contained `.py`: typed dataclass models, a sync `Client` and an a - **`models: pydantic` adds a dependency, and the header says so.** The default mode keeps httpx as the only requirement; the pydantic header asks for both. A mode that quietly needed a package the file never named would fail at import with nothing to act on. +- **Every parameter is its own argument, so their names share one namespace** with the + arguments the method declares itself (`body`, `headers`, `timeout`, `retry`, `idempotency_key`). Build them with + `uniqueIdentifiers(..., { taken: … })`: OpenAPI lets one operation use a name in two + locations (`id` in the path AND in the query), and a `def` that declared one name twice is a `SyntaxError`. The + wire name is untouched, so the request is unchanged. - **Naming:** fields/methods snake*case via `identifierFor(..., RESERVED_WORDS.python)`; reserved words get a trailing underscore (`class*`); `+1`/`-1`become`plus_1`/`minus_1`. - **Enums** are `class X(str, Enum)` with SCREAMING members; **unions** are `Union[...]` diff --git a/packages/client-generator/src/generators/python/index.ts b/packages/client-generator/src/generators/python/index.ts index 71e120112a..a82aed7b33 100644 --- a/packages/client-generator/src/generators/python/index.ts +++ b/packages/client-generator/src/generators/python/index.ts @@ -16,6 +16,7 @@ import { identifierFor, isNullable, RESERVED_WORDS, + uniqueIdentifiers, unwrapNullable, type DateType, } from '../../authoring/index.js'; @@ -162,6 +163,12 @@ function pydanticDiscriminators(model: ApiModel): { return { pins, unions }; } +/** + * The argument names every request method declares itself. A parameter named after one of + * them takes a suffixed binding instead, so the slot keeps its meaning. + */ +const METHOD_ARG_SLOTS = ['self', 'body', 'headers', 'timeout', 'retry', 'idempotency_key']; + function writeDataclass( printer: Printer, name: string, @@ -495,13 +502,18 @@ function writeMethod( model?: ApiModel, envelope = false ): void { - const pathArgs = op.pathParams.map((param) => ({ - param, - python: identifierFor(param.name, { style: 'snake', reserved: PY }), - })); - const queryArgs = op.queryParams.map((param) => ({ + // 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: identifierFor(param.name, { style: 'snake', reserved: PY }), + python: argNames[op.pathParams.length + index], })); const positional = pathArgs.map( ({ param, python }) => `${python}: ${pythonType(param.schema, dateType)}` @@ -618,10 +630,11 @@ function writePaginationWrappers( ): void { const success = successSchema(op); const pageType = success === undefined ? 'Any' : pythonType(success, dateType); - const queryArgs = op.queryParams.map((param) => ({ - param, - python: identifierFor(param.name, { style: 'snake', reserved: PY }), - })); + const iterNames = uniqueIdentifiers( + op.queryParams.map((param) => param.name), + { style: 'snake', reserved: PY, taken: METHOD_ARG_SLOTS } + ); + const queryArgs = op.queryParams.map((param, index) => ({ param, python: iterNames[index] })); const kwargs = [ ...queryArgs.map(({ param, python }) => { const annotation = pythonType(param.schema); diff --git a/packages/client-generator/src/pipeline.ts b/packages/client-generator/src/pipeline.ts index f2b8a732e5..1792d32c4c 100644 --- a/packages/client-generator/src/pipeline.ts +++ b/packages/client-generator/src/pipeline.ts @@ -111,6 +111,38 @@ export function runGenerators( return files; } +/** + * A parameter name used in two locations of one operation — `id` in the path AND in the + * query, which OpenAPI permits. Every SDK still sends both, under their own wire names, but + * the languages that pass parameters as separate arguments have to rename the second one, so + * the publisher should hear about it once and can rename it in the description instead. + */ +function warnRepeatedParamNames(model: ApiModel): void { + for (const service of model.services) { + for (const op of service.operations) { + const seen = new Set(); + const repeated = new Set(); + for (const param of [ + ...op.pathParams, + ...op.queryParams, + ...op.headerParams, + ...op.cookieParams, + ]) { + if (seen.has(param.name)) repeated.add(param.name); + seen.add(param.name); + } + if (repeated.size === 0) continue; + logger.warn( + `generate-client: operation "${op.specName ?? op.name}" uses ${[...repeated] + .map((name) => `"${name}"`) + .join( + ', ' + )} in more than one parameter location. Every SDK sends both, and the SDKs whose methods take one argument per parameter give the later one a suffixed name — rename it in the description to choose the name yourself.\n` + ); + } + } +} + /** * An OpenAPI Overlay (1.0.0) adding per-operation `x-codeSamples`, collected from * every selected generator that implements the `sample` hook; undefined when no @@ -174,6 +206,7 @@ export async function generateClient( normalizeSwagger2(document as unknown as Record) : document; const model = buildApiModel(normalized); + warnRepeatedParamNames(model); // A publisher `--setup` module is read, validated, and transformed into the neutral setup // expression baked into the client. Applied across all output modes by the emitter. diff --git a/tests/e2e/generate-client/fixtures/repeated-params.yaml b/tests/e2e/generate-client/fixtures/repeated-params.yaml new file mode 100644 index 0000000000..e38698a0ec --- /dev/null +++ b/tests/e2e/generate-client/fixtures/repeated-params.yaml @@ -0,0 +1,74 @@ +openapi: 3.1.0 +info: + title: Repeated Params API + version: 1.0.0 + description: >- + Parameter names an SDK cannot take literally: the same name in two locations (which + OpenAPI permits), and names that clash with the arguments a generated method declares + itself (`body`, `headers`, `timeout`, `params`, `ctx`). Every generator must still emit + a module that parses, with the wire names untouched. +servers: + - url: https://api.example.com +paths: + /things/{id}: + get: + operationId: getThing + parameters: + - name: id + in: path + required: true + schema: { type: string } + - name: id + in: query + required: false + description: A filter that happens to share the path parameter's name. + schema: { type: integer } + responses: + '200': + description: One thing. + content: + application/json: + schema: { $ref: '#/components/schemas/Thing' } + /things/{body}/{ctx}: + post: + operationId: makeThing + parameters: + - name: body + in: path + required: true + schema: { type: string } + - name: ctx + in: path + required: true + schema: { type: string } + - name: timeout + in: query + required: false + schema: { type: string } + - name: headers + in: query + required: false + schema: { type: string } + - name: params + in: query + required: false + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/Thing' } + responses: + '200': + description: The created thing. + content: + application/json: + schema: { $ref: '#/components/schemas/Thing' } +components: + schemas: + Thing: + type: object + required: [id] + properties: + id: { type: string } + name: { type: string } diff --git a/tests/e2e/generate-client/go.test.ts b/tests/e2e/generate-client/go.test.ts index d26d00adfd..c67f86f381 100644 --- a/tests/e2e/generate-client/go.test.ts +++ b/tests/e2e/generate-client/go.test.ts @@ -1,5 +1,6 @@ import { spawnSync, type ChildProcess } from 'node:child_process'; -import { existsSync, readFileSync, rmSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -71,3 +72,39 @@ describe('generate-client go generator (end-to-end)', () => { 60_000 ); }); + +describe('generate-client go generator, parameter names an SDK cannot take literally', () => { + let dir: string; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'go-repeated-')); + generate(join(__dirname, 'fixtures/repeated-params.yaml'), join(dir, 'client.ts'), [ + '--generator', + 'go', + ]); + }); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('renames a parameter that clashes with one of the method arguments', () => { + const source = readFileSync(join(dir, 'client.go'), 'utf-8'); + // Query params live in their own struct, so `id` needs no rename here… + expect(source).toContain('func (c *Client) GetThing(ctx context.Context, id string'); + // …but a path parameter named after an argument the method declares itself does. + expect(source).toContain( + 'func (c *Client) MakeThing(ctx context.Context, body2 string, ctx2 string, body Thing' + ); + }); + + it.skipIf(!hasGo)( + 'the generated client compiles (go build)', + () => { + writeFileSync(join(dir, 'go.mod'), 'module repeatedparams\n\ngo 1.21\n', 'utf-8'); + const result = spawnSync('go', ['build', './...'], { cwd: dir, encoding: 'utf-8' }); + expect(result.status, result.stderr).toBe(0); + }, + 180_000 + ); +}); diff --git a/tests/e2e/generate-client/php.test.ts b/tests/e2e/generate-client/php.test.ts index d971022b00..44ab9741c5 100644 --- a/tests/e2e/generate-client/php.test.ts +++ b/tests/e2e/generate-client/php.test.ts @@ -1,5 +1,6 @@ import { spawnSync, type ChildProcess } from 'node:child_process'; -import { existsSync, rmSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -58,3 +59,34 @@ describe('generate-client php generator (end-to-end)', () => { 60_000 ); }); + +describe('generate-client php generator, parameter names an SDK cannot take literally', () => { + let dir: string; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'php-repeated-')); + generate(join(__dirname, 'fixtures/repeated-params.yaml'), join(dir, 'client.ts'), [ + '--generator', + 'php', + ]); + }); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('renames the repeat, keeps the wire name, and still parses', () => { + const source = readFileSync(join(dir, 'client.php'), 'utf-8'); + // `id` in the path and in the query: PHP rejects a redefined parameter outright. + expect(source).toContain('public function getThing(string $id, ?int $id2 = null'); + // A parameter named after one of the signature's own arguments moves aside too. + expect(source).toContain('public function makeThing(string $body2, string $ctx, Thing $body'); + // The request is unchanged: the query keys keep the wire names. + expect(source).toContain("$query['id'] = $id2;"); + }); + + it.skipIf(!hasPhp)('the generated client parses (php -l)', () => { + const lint = spawnSync('php', ['-l', join(dir, 'client.php')], { encoding: 'utf-8' }); + expect(lint.status, `${lint.stdout}\n${lint.stderr}`).toBe(0); + }); +}); diff --git a/tests/e2e/generate-client/python.test.ts b/tests/e2e/generate-client/python.test.ts index f91cd58dcb..b8ff3c1e84 100644 --- a/tests/e2e/generate-client/python.test.ts +++ b/tests/e2e/generate-client/python.test.ts @@ -167,3 +167,38 @@ describe('generate-client python generator, models: pydantic (end-to-end)', () = expect(result.stdout).toContain('PYDANTIC_DISCRIMINATOR_OK'); }); }); + +describe('generate-client python generator, parameter names an SDK cannot take literally', () => { + let dir: string; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'python-repeated-')); + generate(join(__dirname, 'fixtures/repeated-params.yaml'), join(dir, 'client.ts'), [ + '--generator', + 'python', + ]); + }); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('renames the repeat, keeps the wire name, and still parses', () => { + const source = readFileSync(join(dir, 'client.py'), 'utf-8'); + // `id` in the path and in the query: the later one moves aside… + expect(source).toContain('def get_thing(self, id: str, *, id_2: Optional[int] = None'); + // …and a parameter named after one of the method's own arguments does too. + expect(source).toContain('def make_thing(self, body_2: str, ctx: str, body: Thing, *,'); + expect(source).toContain('timeout_2: Optional[str] = None'); + // The request is unchanged: the descriptor and the query keys keep the wire names. + expect(source).toContain('params["id"] = encode(id_2)'); + expect(source).toContain('params["timeout"] = encode(timeout_2)'); + }); + + it.skipIf(!hasPython)('the generated client is valid Python', () => { + const result = spawnSync('python3', ['-m', 'py_compile', join(dir, 'client.py')], { + encoding: 'utf-8', + }); + expect(result.status, result.stderr).toBe(0); + }); +}); From 1ba6e7ae0bc95993f1c47f2d57f6e9ce6191fd26 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 19 Aug 2026 18:37:58 +0300 Subject: [PATCH 205/211] refactor(client-generator)!: drop binName, the generated cli names itself from the command it runs as --- .changeset/agent-friendly-generators.md | 1 + docs/@v2/commands/generate-client.md | 41 ++++++++--------- docs/@v2/configuration/reference/client.md | 1 - docs/@v2/guides/use-generated-client.md | 29 ++++++------ .../commands/eject-generator.test.ts | 4 +- packages/cli/src/commands/generate-client.ts | 15 ++---- packages/cli/src/index.ts | 6 --- .../skills/cli-generator/SKILL.md | 18 ++++---- .../src/emitters/__tests__/cli.test.ts | 1 - .../client-generator/src/emitters/cli-docs.ts | 34 ++++++++------ packages/client-generator/src/emitters/cli.ts | 22 +++++---- .../src/emitters/emit-options.ts | 7 --- .../src/emitters/runtime-sources.ts | 6 +-- .../src/generators/__tests__/cli.test.ts | 21 +++------ .../src/generators/__tests__/index.test.ts | 5 +- .../src/generators/cli/AGENTS.md | 18 ++++---- .../src/generators/cli/index.ts | 14 +----- .../client-generator/src/generators/meta.ts | 26 +++-------- packages/client-generator/src/pipeline.ts | 1 - .../src/runtime/__tests__/cli.test.ts | 11 +++-- packages/client-generator/src/runtime/cli.ts | 46 ++++++++++--------- packages/client-generator/src/types.ts | 2 - .../__snapshots__/redocly-yaml.test.ts.snap | 3 -- packages/core/src/types/redocly-yaml.ts | 1 - tests/e2e/generate-client/cli-compose.test.ts | 9 ++-- .../generate-client/examples/cli/README.md | 2 +- 26 files changed, 148 insertions(+), 196 deletions(-) diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index bbc6545d34..df81fd6346 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -8,6 +8,7 @@ Added agent-friendly client generation: `python`, `go`, `php`, and `cli` generat Added `--docs` (`client.docs`), which writes the reference documentation for what a run generates: each generator documents itself with one Markdown page next to its output. Added composable generated CLIs (custom commands, one binary over several APIs via `client.cliOutput`). +The generated CLI takes its displayed name from the command it is invoked as, so help always names a command that exists, and its credential environment variables come from the output file name (`CAFE_TOKEN` for `cafe.ts`), so installing it under any `bin` name keeps the variables your users set. Added language-neutral authoring toolkit with per-generator options, including `client.options.python.models: pydantic`, which emits `BaseModel` classes instead of dataclasses. diff --git a/docs/@v2/commands/generate-client.md b/docs/@v2/commands/generate-client.md index de026c25ed..f2f5d41540 100644 --- a/docs/@v2/commands/generate-client.md +++ b/docs/@v2/commands/generate-client.md @@ -71,27 +71,26 @@ redocly generate-client [--help] [--version] ## Options -| Option | Type | Description | -| ---------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `api` | string | The file path to the OpenAPI description, a URL, or an `apis:` alias. Omit it to generate a client for each api that has a `client` block or `clientOutput`. | -| `--output`, `-o` | string | The output path (it must end in `.ts`). In multi-file modes, this is the entry file. Defaults to the `clientOutput` of the api, else `.client.ts` next to the configuration file. Use this option only when you generate one API. | -| `--output-mode` | string | The file layout. See [Choose an output mode](#choose-an-output-mode).
**Possible values:** `single`, `split`. Default: `single`. | -| `--runtime` | string | The location of the client engine. See [Choose a runtime](#choose-a-runtime).
**Possible values:** `inline`, `package`. Default: `inline`. | -| `--import-ext` | string | The extension in the generated relative imports. See [Run with Node directly](../guides/use-generated-client.md#run-with-node-directly).
**Possible values:** `js` (the tsc/bundler convention), `ts` (for Node's built-in type stripping). Default: `js`. | -| `--generator` | [string] | The generator to run: a built-in name, or the path or package of a custom generator. Repeat the flag to run more than one generator. Default value is `typescript`. See [Generators](../guides/use-generated-client.md#generators) for the full list. | -| `--args-style` | string | Sets how you pass inputs to operations. See [Argument style](../guides/use-generated-client.md#argument-style).
**Possible values:** `grouped`, `flat`. Default: `grouped`. | -| `--error-mode` | string | Sets how operations report HTTP errors. See [Error handling](../guides/use-generated-client.md#error-handling).
**Possible values:** `throw`, `result`. Default: `throw`. | -| `--date-type` | string | The type of the `date`/`date-time` fields. If you use `Date`, also use the `transformers` generator.
**Possible values:** `string`, `Date`. Default: `string`. | -| `--mock-data` | string | The data mode for the `mock` generator.
**Possible values:** `static` (deterministic literals), `faker` (`@faker-js/faker` calls). Default: `static`. | -| `--mock-seed` | number | The seed for `faker`-mode mocks. Use it to get reproducible data. The command ignores it in `static` mode. | -| `--server-url` | string | Overrides the default server URL in the client. The option accepts an absolute URL (`https://api.example.com`) or a relative URL (`/v1`). Defaults to `servers[0].url`. The app can also change the server URL at runtime with `createClient({ serverUrl })` or `configure({ serverUrl })`. See [Authentication](../guides/use-generated-client.md#authentication) in the usage guide. | -| `--setup` | string | The path to a publisher setup module that the command includes in the client. Use it to pre-configure defaults, for example the server URL, retries, headers, and middleware. A published SDK then contains these defaults. See [Publisher defaults](../guides/customize-client-generation.md#publisher-defaults). | -| `--bin-name` | string | The command name that the `cli` generator prints in the help output. The generator also uses it to derive the names of the credential environment variables. It does not install a command; see [Ship it as a real command](../guides/use-generated-client.md#ship-it-as-a-real-command). Defaults to the output file name (without extension) with non-word characters converted to `-`. | -| `--docs` | boolean | Also write the reference documentation for what this run generates: one Markdown page for each selected generator that documents itself (the CLI, and each SDK). Default value is `false`. | -| `--go-package` | string | The package clause in the output of the `go` generator. It must be a valid Go package name (lowercase letters, digits, and `_`; it must not start with a digit or be a keyword). Default value is `client`. | -| `--config` | string | Specify the path to the [configuration file](#generate-from-the-configuration-file). | -| `--help` | boolean | Display help. | -| `--version` | boolean | Display version number. | +| Option | Type | Description | +| ---------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `api` | string | The file path to the OpenAPI description, a URL, or an `apis:` alias. Omit it to generate a client for each api that has a `client` block or `clientOutput`. | +| `--output`, `-o` | string | The output path (it must end in `.ts`). In multi-file modes, this is the entry file. Defaults to the `clientOutput` of the api, else `.client.ts` next to the configuration file. Use this option only when you generate one API. | +| `--output-mode` | string | The file layout. See [Choose an output mode](#choose-an-output-mode).
**Possible values:** `single`, `split`. Default: `single`. | +| `--runtime` | string | The location of the client engine. See [Choose a runtime](#choose-a-runtime).
**Possible values:** `inline`, `package`. Default: `inline`. | +| `--import-ext` | string | The extension in the generated relative imports. See [Run with Node directly](../guides/use-generated-client.md#run-with-node-directly).
**Possible values:** `js` (the tsc/bundler convention), `ts` (for Node's built-in type stripping). Default: `js`. | +| `--generator` | [string] | The generator to run: a built-in name, or the path or package of a custom generator. Repeat the flag to run more than one generator. Default value is `typescript`. See [Generators](../guides/use-generated-client.md#generators) for the full list. | +| `--args-style` | string | Sets how you pass inputs to operations. See [Argument style](../guides/use-generated-client.md#argument-style).
**Possible values:** `grouped`, `flat`. Default: `grouped`. | +| `--error-mode` | string | Sets how operations report HTTP errors. See [Error handling](../guides/use-generated-client.md#error-handling).
**Possible values:** `throw`, `result`. Default: `throw`. | +| `--date-type` | string | The type of the `date`/`date-time` fields. If you use `Date`, also use the `transformers` generator.
**Possible values:** `string`, `Date`. Default: `string`. | +| `--mock-data` | string | The data mode for the `mock` generator.
**Possible values:** `static` (deterministic literals), `faker` (`@faker-js/faker` calls). Default: `static`. | +| `--mock-seed` | number | The seed for `faker`-mode mocks. Use it to get reproducible data. The command ignores it in `static` mode. | +| `--server-url` | string | Overrides the default server URL in the client. The option accepts an absolute URL (`https://api.example.com`) or a relative URL (`/v1`). Defaults to `servers[0].url`. The app can also change the server URL at runtime with `createClient({ serverUrl })` or `configure({ serverUrl })`. See [Authentication](../guides/use-generated-client.md#authentication) in the usage guide. | +| `--setup` | string | The path to a publisher setup module that the command includes in the client. Use it to pre-configure defaults, for example the server URL, retries, headers, and middleware. A published SDK then contains these defaults. See [Publisher defaults](../guides/customize-client-generation.md#publisher-defaults). | +| `--docs` | boolean | Also write the reference documentation for what this run generates: one Markdown page for each selected generator that documents itself (the CLI, and each SDK). Default value is `false`. | +| `--go-package` | string | The package clause in the output of the `go` generator. It must be a valid Go package name (lowercase letters, digits, and `_`; it must not start with a digit or be a keyword). Default value is `client`. | +| `--config` | string | Specify the path to the [configuration file](#generate-from-the-configuration-file). | +| `--help` | boolean | Display help. | +| `--version` | boolean | Display version number. | ## Examples diff --git a/docs/@v2/configuration/reference/client.md b/docs/@v2/configuration/reference/client.md index 8fdf367309..ecfa9b3db5 100644 --- a/docs/@v2/configuration/reference/client.md +++ b/docs/@v2/configuration/reference/client.md @@ -37,7 +37,6 @@ As an alternative, pass `pagination` to the programmatic `generateClient(...)`. | `codeSamples` | boolean | Emit `.code-samples.yaml` next to the client. This file is an OpenAPI Overlay that adds `x-codeSamples` to each operation. The samples come from each selected generator that implements `sample()`. This option is available only in the configuration file and has no flag. | | `serverUrl` | string | The server URL that the client includes as its default. If you do not set it, the client uses `servers[0].url`. | | `goPackage` | string | The package clause for the output of the `go` generator. The value must be a valid Go package name: lowercase letters, digits, and `_`, with no digit at the start, and not a keyword. An invalid value stops generation, so the generator does not emit a file that Go cannot compile. Default: `client`. | -| `binName` | string | The command name that the `cli` generator shows in the help output and uses to derive its credential environment variables. It does not install a command: see [Ship it as a real command](../../guides/use-generated-client.md#ship-it-as-a-real-command). The default is the output file name (without extension), sanitized. | | `cliOutput` | string | The path of a composed CLI entry. The entry includes every api that emits a cli module: from the `cli` generator by name, ejected, or included as a prerequisite. The result is one binary. You address each api by its alias, and each api has `__*` credential variables. This option is available only in the top-level `client` block. See [Compose and extend the CLI](../../guides/use-generated-client.md#compose-and-extend-the-cli). | | `options` | object | Options for each generator, keyed by generator name. The command validates each entry against the schema that the generator declares. The `python` generator accepts `models`: `dataclass` (default) or `pydantic`. See [Custom generators](../../guides/customize-client-generation.md#custom-generators). | | `docs` | boolean | Also write the reference documentation for what the run generates: one Markdown page for each selected generator that documents itself (`.cli.md`, `.python.md`, and so on). The `--docs` flag sets it too. Default `false`. | diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index 046347a1e3..0c112a5773 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -80,7 +80,8 @@ The same section shows the environment variables that the CLI reads. The CLI reads credentials from environment variables. The prefix is the output file name in constant case: `MY_API_*` for `my-api.ts`. -The `binName` option overrides the prefix. +The prefix is fixed when the file is generated, so the variables stay the same whatever you install the command as. +To change them, rename the output file. For bearer auth, use `_TOKEN` (or `--token`). For basic auth, use `_USERNAME` and `_PASSWORD`. For apiKey auth, use `_API_KEY_`. @@ -121,11 +122,10 @@ This makes two things possible without changes to the generated files. Set a top-level `client.cliOutput`. Then `redocly generate-client` (no api argument) emits a composed entry for every api that emits a cli module. Each api's alias from `apis:` becomes its command namespace (`shop` and `kitchen` below). -The CLI reads each api's credentials under `__*`: +The CLI reads each api's credentials under `__*`, where `` is the entry file name in constant case: ```yaml client: - binName: cafe cliOutput: ./src/cafe.ts generators: [typescript, cli] apis: @@ -138,7 +138,7 @@ npx tsx src/cafe.ts shop listOrders --limit 3 # CAFE_SHOP_TOKEN npx tsx src/cafe.ts kitchen createOrder --json @o.json # CAFE_KITCHEN_TOKEN ``` -Two different things can stand in the word after the bin name, so compare the two setups. +Two different things can stand in the word after the command, so compare the two setups. For one API, an operationId is the whole command (`cafe listOrders`), and a tag slug goes in front of it only to resolve an ambiguous name (`cafe orders listOrders`). For a composed binary, that first word is the api alias, because an operationId is unique only inside one description: `cafe shop listOrders`. A tag group of that api nests inside its alias, again only when it is needed: `cafe shop orders listOrders`. @@ -148,9 +148,8 @@ Because of this, each command carries its api's alias as a namespace. If two descriptions declare the same operationId, the result is two different commands. Each api keeps its own server URL, schemes, and credentials. -`binName` is the name the CLI uses for itself. -The name appears in every usage line of `--help`, and the credential variables derive from it: `binName: cafe` gives `CAFE_TOKEN`. -It does not create an executable. +The CLI has no name of its own to configure. +It reads the name it was invoked as from the process, so `--help` always shows the command you typed. To type `cafe` instead of `npx tsx src/cafe.ts`, compile the entry and point the `bin` field of your `package.json` at the compiled file. The end of this section shows this step. @@ -191,18 +190,21 @@ This section shows the procedure. #### Ship it as a real command -`binName` is the name the CLI uses for itself, not an installation. +The command name is yours, and generation never sets it. The generated file is a module until you point a `bin` field at it, and these three steps are what make `cafe` a command on your machine. First, the CLI uses top-level `await`, so the nearest `package.json` must set `"type": "module"`. Without this setting, `tsx` reports `Top-level await is currently not supported with the "cjs" output format`, and that message does not point to the fix. -Second, compile the entry with `tsc` and declare the compiled file as the bin: +Second, compile the entry with `tsc` and declare the compiled file as the bin. +For one API the entry is the CLI module, `.cli.ts`, so `src/cafe.ts` compiles to `dist/cafe.cli.js`. +For a composed binary the entry is `cliOutput` itself, so `./src/cafe.ts` compiles to `dist/cafe.js`. +The client module is not an entry, and a `bin` field that points at it gives you a command that does nothing: ```json { "type": "module", - "bin": { "cafe": "./dist/cafe.js" }, + "bin": { "cafe": "./dist/cafe.cli.js" }, "scripts": { "build": "tsc" } } ``` @@ -214,9 +216,10 @@ npm run build && npm link cafe listOrders --limit 3 # CAFE_TOKEN from the environment ``` -Keep the `bin` key and `binName` the same, or the help output names a command that does not exist. -For a one-off run, `npx tsx src/cafe.ts listOrders --limit 3` uses the same entry with no build step. -`binName` applies to the `cli` generator only: the `python`, `go`, and `php` SDKs are libraries, and they emit no command. +The help output follows the name you install, because the CLI reads it from the process. +The credential variables do not: they come from the output file name, so a renamed command never invalidates the variables your users already set. +For a one-off run, `npx tsx src/cafe.cli.ts listOrders --limit 3` uses the same entry with no build step. +Only the `cli` generator emits a command: the `python`, `go`, and `php` SDKs are libraries. ### Language SDKs diff --git a/packages/cli/src/__tests__/commands/eject-generator.test.ts b/packages/cli/src/__tests__/commands/eject-generator.test.ts index d8f186d222..277e3e2360 100644 --- a/packages/cli/src/__tests__/commands/eject-generator.test.ts +++ b/packages/cli/src/__tests__/commands/eject-generator.test.ts @@ -69,7 +69,7 @@ describe('wireConfig', () => { expect( wire(outdent` client: - binName: cafe + runtime: package apis: cafe: root: ./openapi.yaml @@ -78,7 +78,7 @@ describe('wireConfig', () => { client: generators: - ./generators/php.mjs - binName: cafe + runtime: package apis: cafe: root: ./openapi.yaml diff --git a/packages/cli/src/commands/generate-client.ts b/packages/cli/src/commands/generate-client.ts index a47ccc76a0..13ea826a11 100644 --- a/packages/cli/src/commands/generate-client.ts +++ b/packages/cli/src/commands/generate-client.ts @@ -34,7 +34,6 @@ export type GenerateClientCommandArgv = { 'output-mode'?: 'single' | 'split'; runtime?: 'inline' | 'package'; 'import-ext'?: 'js' | 'ts'; - 'bin-name'?: string; 'go-package'?: string; 'args-style'?: 'flat' | 'grouped'; 'error-mode'?: 'throw' | 'result'; @@ -110,7 +109,6 @@ export async function handleGenerateClient({ outputMode: argv['output-mode'], runtime: argv.runtime, importExt: argv['import-ext'], - binName: argv['bin-name'], goPackage: argv['go-package'], argsStyle: argv['args-style'], errorMode: argv['error-mode'], @@ -172,7 +170,7 @@ export async function handleGenerateClient({ argv.api === undefined && run.composable.length > 0 ) { - await writeComposedCliEntry(topLevelClient.cliOutput, topLevelClient.binName, run); + await writeComposedCliEntry(topLevelClient.cliOutput, run); } } @@ -255,7 +253,6 @@ async function generateApiClient( * its alias as a namespace. */ async function writeComposedCliEntry( cliOutput: string, - configuredBinName: string | undefined, { configDir, seenOutputs, composable }: GenerationRun ): Promise { const { renderComposedCliEntry } = await import('@redocly/client-generator/generate'); @@ -270,17 +267,13 @@ async function writeComposedCliEntry( `\n❌ client.cliOutput resolves to a file this run generated: ${entryPath}.\n Give the composed entry its own path.\n` ); } - const binName = - configuredBinName ?? - basename(entryPath, extname(entryPath)) - .replace(/[^A-Za-z0-9]+/g, '-') - .toLowerCase(); + const stem = basename(entryPath, extname(entryPath)); const content = renderComposedCliEntry( composable.map(({ alias, cliPath }) => ({ alias, modulePath: `./${relative(dirname(entryPath), cliPath).split('\\').join('/')}`, })), - binName + stem ); await mkdir(dirname(entryPath), { recursive: true }); await writeFile(entryPath, content, 'utf-8'); @@ -290,7 +283,7 @@ async function writeComposedCliEntry( blue( `Composed CLI written to ${yellow(relative(process.cwd(), entryPath))} — ${composable .map(({ alias }) => alias) - .join(', ')} behind one \`${binName}\` binary.` + .join(', ')} behind one \`${stem}\` binary.` ) + '\n' ); diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 4f19359a54..f2685b9349 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -900,12 +900,6 @@ yargs(hideBin(process.argv)) choices: ['inline', 'package'] as const, requiresArg: true, }, - 'bin-name': { - description: - "Command name for the `cli` generator's help output and credential env vars; it does not install a command. Defaults to the output stem.", - type: 'string', - requiresArg: true, - }, docs: { description: 'Also write reference documentation for what this run generates: one Markdown page per selected generator that documents itself (the CLI, and each SDK).', 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 57b68a340c..5aa8ca6dcc 100644 --- a/packages/client-generator/eject-assets/skills/cli-generator/SKILL.md +++ b/packages/client-generator/eject-assets/skills/cli-generator/SKILL.md @@ -34,15 +34,15 @@ command with its positionals and flags. A bare operationId resolves to its grouped command when unambiguous. - **Exit codes are a contract:** 0 ok, 1 API error, 2 auth, 3 validation, 4 usage. Errors print ONE JSON object to stderr so stdout stays pipeable. -- **The bin name is a command name, not a filename.** It defaults to the output stem with - dots and other non-word characters folded to `-` (`openapi.client` → `openapi-client`), - because the stem follows the TypeScript file convention and a usage line reading - `openapi.client orders …` looks like a path. `client.binName` overrides it. -- **Credentials come from the environment** — a prefix derived from the bin name - (`CLIENT_TOKEN`), overridable via `wiring.envPrefix` — or explicit flags; `--dry-run` - prints the prepared request with credentials REDACTED. Help lists only the credentials - the description declares, and an unusable `--token` is a usage error, never silently - dropped. +- **The CLI names itself from `process.argv[1]`.** Only the operator's `bin` field decides + what the command is called, so help reads the invoked name back instead of printing a + name from generation that may not exist on the machine. +- **Credentials come from the environment** — `wiring.envPrefix`, the constant-cased output + stem (`CLIENT_TOKEN`), which a composed entry sets per api alias — or explicit flags; + `--dry-run` prints the prepared request with credentials REDACTED. The prefix is fixed at + generation on purpose: a renamed binary must keep reading the variables a published CLI + already documents. Help lists only the credentials the description declares, and an + unusable `--token` is a usage error, never silently dropped. - **Validation is on by default.** The generator declares `requires: ['typescript', 'zod']` and the pipeline pulls prerequisites in automatically, so `--generator cli` alone produces a validating CLI — a user shouldn't have to know which other generator provides it. The diff --git a/packages/client-generator/src/emitters/__tests__/cli.test.ts b/packages/client-generator/src/emitters/__tests__/cli.test.ts index d96d68b8c5..ef82d28869 100644 --- a/packages/client-generator/src/emitters/__tests__/cli.test.ts +++ b/packages/client-generator/src/emitters/__tests__/cli.test.ts @@ -224,7 +224,6 @@ describe('renderCliModule', () => { importExt: 'js', runtime: 'inline' as const, zodSelected: false, - binName: 'cafe', }; it('emits a shebang entry that wires node bindings and embeds the cli runtime inline', () => { diff --git a/packages/client-generator/src/emitters/cli-docs.ts b/packages/client-generator/src/emitters/cli-docs.ts index 9b1ca81be7..5fbe7f8850 100644 --- a/packages/client-generator/src/emitters/cli-docs.ts +++ b/packages/client-generator/src/emitters/cli-docs.ts @@ -1,18 +1,20 @@ // The cli-docs emitter: renders the Markdown reference for the generated CLI from the -// SAME command table `runCli` dispatches on, and the same `groupSlug`/`envPrefix` the +// SAME command table `runCli` dispatches on, and the same `groupSlug`/`constantCase` the // runtime addresses groups and reads credentials with. A second model would drift from // the tool the first time either side changed. import { Printer } from '../authoring/printer.js'; -import { envPrefix, 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. */ title: string; /** Emit YAML front matter carrying the title, for docs sites that expect it. */ frontmatter: boolean; - /** The command name the CLI prints and derives its credential variables from. */ - binName: string; + /** The generated file's stem: what the page calls the command, and what its credential + * variables derive from. A reader who installs it under another bin name renames only + * the command — the variables are fixed at generation. */ + name: string; /** Auth schemes the description declares, in the order the CLI resolves them. */ schemes: Array<{ key: string; kind: 'bearer' | 'basic' | 'apiKey' }>; }; @@ -29,9 +31,9 @@ function address(command: CliCommand): string { .join(' '); } -function usageLine(binName: string, command: CliCommand): string { +function usageLine(name: string, command: CliCommand): string { const words = [ - binName, + name, address(command), ...command.positionals.map((positional) => `<${positional.name}>`), ...command.flags.filter((flag) => flag.required).map((flag) => `--${flag.name} <${flag.type}>`), @@ -70,7 +72,7 @@ function writeCommand(printer: Printer, command: CliCommand, options: CliDocsOpt printer.line(`\`${command.method} ${command.path}\``); printer.blank(); printer.line('```sh'); - printer.line(usageLine(options.binName, command)); + printer.line(usageLine(options.name, command)); printer.line('```'); printer.blank(); if (command.positionals.length > 0) { @@ -111,7 +113,7 @@ export function renderCliDocs(commands: CliCommand[], options: CliDocsOptions): printer.line(`# ${options.title}`); printer.blank(); printer.line( - `Generated command-line reference for \`${options.binName}\`, produced from the API description by \`redocly generate-client\`.` + `Generated command-line reference for \`${options.name}\`, produced from the API description by \`redocly generate-client\`.` ); printer.line('Re-run generation to update it — this file is not hand-edited.'); printer.blank(); @@ -119,11 +121,15 @@ export function renderCliDocs(commands: CliCommand[], options: CliDocsOptions): printer.line('## Usage'); printer.blank(); printer.line('```sh'); - printer.line(`${options.binName} [flags]`); - printer.line(`${options.binName} --help`); - printer.line(`${options.binName} schema # request/response schemas`); + printer.line(`${options.name} [flags]`); + printer.line(`${options.name} --help`); + printer.line(`${options.name} schema # request/response schemas`); printer.line('```'); printer.blank(); + printer.line( + 'Install the file under any `bin` name: the command takes that name, and the credential variables below do not change.' + ); + printer.blank(); printer.line('## Global flags'); printer.blank(); @@ -145,7 +151,7 @@ export function renderCliDocs(commands: CliCommand[], options: CliDocsOptions): } printer.blank(); - const prefix = envPrefix(options.binName); + const prefix = constantCase(options.name); printer.line('## Credentials'); printer.blank(); if (options.schemes.length === 0) { @@ -161,7 +167,7 @@ export function renderCliDocs(commands: CliCommand[], options: CliDocsOptions): ? `\`${prefix}_TOKEN\` (or \`--token\`)` : scheme.kind === 'basic' ? `\`${prefix}_USERNAME\` and \`${prefix}_PASSWORD\`` - : `\`${prefix}_API_KEY_${envPrefix(scheme.key)}\``; + : `\`${prefix}_API_KEY_${constantCase(scheme.key)}\``; printer.line(`| ${scheme.kind} (\`${scheme.key}\`) | ${variable} |`); } } @@ -195,7 +201,7 @@ export function renderCliDocs(commands: CliCommand[], options: CliDocsOptions): } else { printer.line(`## ${group}`); printer.blank(); - printer.line(`Addressed as \`${options.binName} ${groupSlug(group)} \`.`); + printer.line(`Addressed as \`${options.name} ${groupSlug(group)} \`.`); printer.blank(); } for (const command of inGroup) writeCommand(printer, command, options); diff --git a/packages/client-generator/src/emitters/cli.ts b/packages/client-generator/src/emitters/cli.ts index 6c2bf0f1ed..e79318e74b 100644 --- a/packages/client-generator/src/emitters/cli.ts +++ b/packages/client-generator/src/emitters/cli.ts @@ -11,7 +11,13 @@ import type { ParamModel, SchemaModel, } from '../intermediate-representation/model.js'; -import { groupSlug, type CliAuthScheme, type CliCommand, type CliFlag } from '../runtime/cli.js'; +import { + constantCase, + groupSlug, + type CliAuthScheme, + type CliCommand, + type CliFlag, +} from '../runtime/cli.js'; import { HEADER } from './emit-options.js'; import { embedCliRuntime } from './inline-runtime.js'; import { resolveOperationPagination, type PaginationConfig } from './pagination.js'; @@ -152,7 +158,6 @@ export type CliModuleOptions = { importExt: string; runtime: 'inline' | 'package'; zodSelected: boolean; - binName: string; pagination?: PaginationConfig; /** The sibling client's call shape, which the dispatcher builds its inputs for. */ argsStyle?: 'grouped' | 'flat'; @@ -209,7 +214,7 @@ export function renderCliModule(model: ApiModel, options: CliModuleOptions): str const parts = [ '#!/usr/bin/env node', HEADER, - 'import { readFileSync, realpathSync, writeFileSync } from "node:fs";\nimport { fileURLToPath } from "node:url";', + 'import { readFileSync, realpathSync, writeFileSync } from "node:fs";\nimport { basename } from "node:path";\nimport { fileURLToPath } from "node:url";', [ ...(options.runtime === 'package' ? ['import { runCli, type CliCommand, type CliWiring } from "@redocly/client-generator";'] @@ -232,7 +237,8 @@ export function renderCliModule(model: ApiModel, options: CliModuleOptions): str ] : []), `export const wiring: CliWiring = { - binName: ${codeJson(options.binName)}, + name: basename(process.argv[1] ?? ${codeJson(options.stem)}), + envPrefix: ${codeJson(constantCase(options.stem))}, client, ${options.argsStyle === 'flat' ? ' argsStyle: "flat",\n' : ''} configure, schemes: ${codeJson(schemes)}, @@ -273,8 +279,8 @@ export type ComposedCliSource = { * inline runtime's zero-dependency promise holds — and exports `SOURCES` so an adopter * layers custom commands (a `login`) around it without editing a generated file. */ -export function renderComposedCliEntry(sources: ComposedCliSource[], binName: string): string { - const prefix = binName.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase(); +export function renderComposedCliEntry(sources: ComposedCliSource[], stem: string): string { + const prefix = constantCase(stem); // An identifier can't start with a digit, and two aliases can sanitize identically — // the underscore and the index keep every import binding legal and unique. const idents = new Map(); @@ -292,11 +298,11 @@ export function renderComposedCliEntry(sources: ComposedCliSource[], binName: st const entries = sources.map(({ alias }) => { const ident = identFor(alias); const namespace = kebab(alias); - const aliasPrefix = `${prefix}_${alias.replace(/[^A-Za-z0-9]+/g, '_').toUpperCase()}`; + const aliasPrefix = `${prefix}_${constantCase(alias)}`; return ` { namespace: ${JSON.stringify(namespace)}, commands: ${ident}Commands, - wiring: { ...${ident}Wiring, binName: ${JSON.stringify(binName)}, envPrefix: ${JSON.stringify(aliasPrefix)} }, + wiring: { ...${ident}Wiring, envPrefix: ${JSON.stringify(aliasPrefix)} }, },`; }); return ( diff --git a/packages/client-generator/src/emitters/emit-options.ts b/packages/client-generator/src/emitters/emit-options.ts index 19d1ad2e53..de87942898 100644 --- a/packages/client-generator/src/emitters/emit-options.ts +++ b/packages/client-generator/src/emitters/emit-options.ts @@ -57,13 +57,6 @@ export type EmitOptions = { * built-in type stripping (`node client.ts`). */ importExt?: 'js' | 'ts'; - /** - * Command name the `cli` generator uses in help output and to derive its credential - * environment variables. Defaults to the output stem with non-word characters folded - * to `-` — the stem follows the TypeScript file convention, and `openapi.client` reads - * as a filename in a usage line. - */ - binName?: string; /** * 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 diff --git a/packages/client-generator/src/emitters/runtime-sources.ts b/packages/client-generator/src/emitters/runtime-sources.ts index 683ceb4a95..4affa02283 100644 --- a/packages/client-generator/src/emitters/runtime-sources.ts +++ b/packages/client-generator/src/emitters/runtime-sources.ts @@ -25,7 +25,7 @@ export const RUNTIME_SOURCES = { 'paginate.ts': "import type { OperationArgs } from './create-client.js';\nimport type { PaginationSpec, QueryValue, RequestOptions } from './types.js';\n\n/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `query` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nexport function resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `query[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nexport async function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.query?.[spec.param];\n while (true) {\n const query = { ...args.query };\n if (cursor !== undefined) query[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, query }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `query[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.query?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call({ ...args, query: { ...args.query, [spec.param]: position } }, init);\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nexport async function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\nexport type LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nexport function linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nexport async function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let query = args.query;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, query }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n query = { ...args.query, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nexport async function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n", 'cli.ts': - "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /**\n * Present when the operation takes a JSON request body. `merged` marks a body whose own\n * properties a flat-style call spells at the top level (the generator decides this from\n * the schema, so the CLI and the client can never disagree).\n */\n body?: { required: boolean; merged?: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n binName: string;\n /** Credential variable prefix. Defaults to `binName`, constant-cased — set it when the\n * displayed name and the credential family must differ (a composed multi-API binary). */\n envPrefix?: string;\n /** The generated instance client. */\n client: Record;\n /** How that client takes its inputs. Defaults to `'grouped'`, the generated default. */\n argsStyle?: 'grouped' | 'flat';\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\nexport type CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\nexport type CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\nexport type CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\nexport type CommandSource = {\n namespace?: string;\n commands: Array;\n /** Absent = inherit the first wired source's — a root `login` shares the binary's identity. */\n wiring?: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nexport function groupSlug(group: string): string {\n return group\n .toLowerCase()\n .split(/[^a-z0-9]+/)\n .filter(Boolean)\n .join('-');\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/**\n * The parsed argv as one call input, in the style the wired client takes: grouped by layer\n * (the default) or merged into one object.\n */\nfunction callInputs(\n command: CliCommand,\n positionals: Record,\n params: Record,\n body: unknown,\n argsStyle: CliWiring['argsStyle']\n): Record | undefined {\n const inputs: Record = {};\n if (argsStyle === 'flat') {\n Object.assign(inputs, positionals, params);\n if (body !== undefined) {\n if (command.body?.merged === true) Object.assign(inputs, body as Record);\n else inputs.body = body;\n }\n } else {\n if (Object.keys(positionals).length > 0) inputs.path = positionals;\n if (Object.keys(params).length > 0) inputs.query = params;\n if (body !== undefined) inputs.body = body;\n }\n return Object.keys(inputs).length > 0 ? inputs : undefined;\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n // An untagged operation is only ever addressed by its bare name, so when that name is also\n // a group slug the name wins — reading it as the group would leave the command unreachable.\n // A tagged operation in the same position keeps yielding to group help: it is still\n // reachable as ` `.\n const untagged = commands.some((c) => c.group === undefined && c.name === argv[0]);\n let command: CliCommand | undefined;\n let rest: string[];\n if (!untagged && slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nexport function envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nexport async function runCli(sources: CommandSource[], argv: string[]): Promise;\nexport async function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // A source without wiring inherits the first wired one, so the documented root-source\n // shape `{ commands: [login] }` works: the login shares the composed binary's identity.\n const inherited = sources.find((source) => source.wiring !== undefined)?.wiring;\n const wiringOf = (source: CommandSource): CliWiring => source.wiring ?? (inherited as CliWiring);\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = wiringOf(sources[0]);\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.binName)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, wiringOf(source), argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, wiringOf(root as CommandSource), argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], binName: string): string[] {\n const lines = [`Usage: ${binName} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${binName} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n for (const line of renderHelp(\n commands,\n wiring.binName,\n wiring.schemes ?? [],\n prefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n // Redaction matches these against header VALUES — so basic auth must contribute the\n // form the wire actually carries (`Basic ${base64(user:pass)}`), not just the raw\n // password, which never appears in the encoded header.\n const basic = auth.basic as { username: string; password: string } | undefined;\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(basic ? [basic.password, btoa(`${basic.username}:${basic.password}`)] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const argument = callInputs(command, positionals, params, body, wiring.argsStyle);\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n // An SSE method returns a lazy stream — drain the stubbed response so its fetch\n // actually runs and captures the request.\n if (command.sse) for await (const _event of result as AsyncIterable);\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", + "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /**\n * Present when the operation takes a JSON request body. `merged` marks a body whose own\n * properties a flat-style call spells at the top level (the generator decides this from\n * the schema, so the CLI and the client can never disagree).\n */\n body?: { required: boolean; merged?: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n /** The name the CLI is invoked as, for help output only. The generated entry reads it\n * from `process.argv[1]`, so help never names a command that is not installed. */\n name: string;\n /** Credential variable prefix, constant-cased: `CAFE` gives `CAFE_TOKEN`. Fixed at\n * generation from the output file name, so renaming the binary keeps the variables\n * a published CLI already documents. A composed entry sets one per api alias. */\n envPrefix: string;\n /** The generated instance client. */\n client: Record;\n /** How that client takes its inputs. Defaults to `'grouped'`, the generated default. */\n argsStyle?: 'grouped' | 'flat';\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\nexport type CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\nexport type CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\nexport type CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\nexport type CommandSource = {\n namespace?: string;\n commands: Array;\n /** Absent = inherit the first wired source's — a root `login` shares the binary's identity. */\n wiring?: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nexport function groupSlug(group: string): string {\n return group\n .toLowerCase()\n .split(/[^a-z0-9]+/)\n .filter(Boolean)\n .join('-');\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/**\n * The parsed argv as one call input, in the style the wired client takes: grouped by layer\n * (the default) or merged into one object.\n */\nfunction callInputs(\n command: CliCommand,\n positionals: Record,\n params: Record,\n body: unknown,\n argsStyle: CliWiring['argsStyle']\n): Record | undefined {\n const inputs: Record = {};\n if (argsStyle === 'flat') {\n Object.assign(inputs, positionals, params);\n if (body !== undefined) {\n if (command.body?.merged === true) Object.assign(inputs, body as Record);\n else inputs.body = body;\n }\n } else {\n if (Object.keys(positionals).length > 0) inputs.path = positionals;\n if (Object.keys(params).length > 0) inputs.query = params;\n if (body !== undefined) inputs.body = body;\n }\n return Object.keys(inputs).length > 0 ? inputs : undefined;\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n // An untagged operation is only ever addressed by its bare name, so when that name is also\n // a group slug the name wins — reading it as the group would leave the command unreachable.\n // A tagged operation in the same position keeps yielding to group help: it is still\n // reachable as ` `.\n const untagged = commands.some((c) => c.group === undefined && c.name === argv[0]);\n let command: CliCommand | undefined;\n let rest: string[];\n if (!untagged && slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** `cafe-api` → `CAFE_API`: the casing of every credential variable this CLI reads. */\nexport function constantCase(value: string): string {\n return value\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix;\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${constantCase(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n name: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n name,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${name} ${topic} …`, '', 'Commands:']\n : [`Usage: ${name} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${constantCase(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${name} ${grouped ? ' ' : ''} --help for command details; ${name} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nexport async function runCli(sources: CommandSource[], argv: string[]): Promise;\nexport async function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // A source without wiring inherits the first wired one, so the documented root-source\n // shape `{ commands: [login] }` works: the login shares the composed binary's identity.\n const inherited = sources.find((source) => source.wiring !== undefined)?.wiring;\n const wiringOf = (source: CommandSource): CliWiring => source.wiring ?? (inherited as CliWiring);\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = wiringOf(sources[0]);\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.name)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, wiringOf(source), argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, wiringOf(root as CommandSource), argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], name: string): string[] {\n const lines = [`Usage: ${name} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${name} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(\n commands,\n wiring.name,\n wiring.schemes ?? [],\n wiring.envPrefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n // Redaction matches these against header VALUES — so basic auth must contribute the\n // form the wire actually carries (`Basic ${base64(user:pass)}`), not just the raw\n // password, which never appears in the encoded header.\n const basic = auth.basic as { username: string; password: string } | undefined;\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(basic ? [basic.password, btoa(`${basic.username}:${basic.password}`)] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const argument = callInputs(command, positionals, params, body, wiring.argsStyle);\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n // An SSE method returns a lazy stream — drain the stubbed response so its fetch\n // actually runs and captures the request.\n if (command.sse) for await (const _event of result as AsyncIterable);\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", } as const; /** Inline-embed variants: imports dropped, `export` stripped outside the kept surface. */ @@ -55,7 +55,7 @@ export const RUNTIME_SOURCES_STRIPPED = { 'paginate.ts': "/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `query` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nfunction resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `query[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nasync function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.query?.[spec.param];\n while (true) {\n const query = { ...args.query };\n if (cursor !== undefined) query[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, query }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `query[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.query?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call({ ...args, query: { ...args.query, [spec.param]: position } }, init);\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nasync function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\ntype LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nfunction linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nasync function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let query = args.query;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, query }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n query = { ...args.query, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nasync function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}", 'cli.ts': - "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\ntype CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\ntype CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /**\n * Present when the operation takes a JSON request body. `merged` marks a body whose own\n * properties a flat-style call spells at the top level (the generator decides this from\n * the schema, so the CLI and the client can never disagree).\n */\n body?: { required: boolean; merged?: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\ntype CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\ntype CliWiring = {\n binName: string;\n /** Credential variable prefix. Defaults to `binName`, constant-cased — set it when the\n * displayed name and the credential family must differ (a composed multi-API binary). */\n envPrefix?: string;\n /** The generated instance client. */\n client: Record;\n /** How that client takes its inputs. Defaults to `'grouped'`, the generated default. */\n argsStyle?: 'grouped' | 'flat';\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\ntype CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\ntype CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\ntype CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\ntype CommandSource = {\n namespace?: string;\n commands: Array;\n /** Absent = inherit the first wired source's — a root `login` shares the binary's identity. */\n wiring?: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nfunction groupSlug(group: string): string {\n return group\n .toLowerCase()\n .split(/[^a-z0-9]+/)\n .filter(Boolean)\n .join('-');\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/**\n * The parsed argv as one call input, in the style the wired client takes: grouped by layer\n * (the default) or merged into one object.\n */\nfunction callInputs(\n command: CliCommand,\n positionals: Record,\n params: Record,\n body: unknown,\n argsStyle: CliWiring['argsStyle']\n): Record | undefined {\n const inputs: Record = {};\n if (argsStyle === 'flat') {\n Object.assign(inputs, positionals, params);\n if (body !== undefined) {\n if (command.body?.merged === true) Object.assign(inputs, body as Record);\n else inputs.body = body;\n }\n } else {\n if (Object.keys(positionals).length > 0) inputs.path = positionals;\n if (Object.keys(params).length > 0) inputs.query = params;\n if (body !== undefined) inputs.body = body;\n }\n return Object.keys(inputs).length > 0 ? inputs : undefined;\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nfunction parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n // An untagged operation is only ever addressed by its bare name, so when that name is also\n // a group slug the name wins — reading it as the group would leave the command unreachable.\n // A tagged operation in the same position keeps yielding to group help: it is still\n // reachable as ` `.\n const untagged = commands.some((c) => c.group === undefined && c.name === argv[0]);\n let command: CliCommand | undefined;\n let rest: string[];\n if (!untagged && slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */\nfunction envPrefix(binName: string): string {\n return binName\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${envPrefix(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n binName: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n binName,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${binName} ${topic} …`, '', 'Commands:']\n : [`Usage: ${binName} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nasync function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nasync function runCli(sources: CommandSource[], argv: string[]): Promise;\nasync function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // A source without wiring inherits the first wired one, so the documented root-source\n // shape `{ commands: [login] }` works: the login shares the composed binary's identity.\n const inherited = sources.find((source) => source.wiring !== undefined)?.wiring;\n const wiringOf = (source: CommandSource): CliWiring => source.wiring ?? (inherited as CliWiring);\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = wiringOf(sources[0]);\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.binName)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, wiringOf(source), argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, wiringOf(root as CommandSource), argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], binName: string): string[] {\n const lines = [`Usage: ${binName} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${binName} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n const prefix = wiring.envPrefix ?? envPrefix(wiring.binName);\n for (const line of renderHelp(\n commands,\n wiring.binName,\n wiring.schemes ?? [],\n prefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n // Redaction matches these against header VALUES — so basic auth must contribute the\n // form the wire actually carries (`Basic ${base64(user:pass)}`), not just the raw\n // password, which never appears in the encoded header.\n const basic = auth.basic as { username: string; password: string } | undefined;\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(basic ? [basic.password, btoa(`${basic.username}:${basic.password}`)] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const argument = callInputs(command, positionals, params, body, wiring.argsStyle);\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n // An SSE method returns a lazy stream — drain the stubbed response so its fetch\n // actually runs and captures the request.\n if (command.sse) for await (const _event of result as AsyncIterable);\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}", + "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\ntype CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\ntype CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /**\n * Present when the operation takes a JSON request body. `merged` marks a body whose own\n * properties a flat-style call spells at the top level (the generator decides this from\n * the schema, so the CLI and the client can never disagree).\n */\n body?: { required: boolean; merged?: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\ntype CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\ntype CliWiring = {\n /** The name the CLI is invoked as, for help output only. The generated entry reads it\n * from `process.argv[1]`, so help never names a command that is not installed. */\n name: string;\n /** Credential variable prefix, constant-cased: `CAFE` gives `CAFE_TOKEN`. Fixed at\n * generation from the output file name, so renaming the binary keeps the variables\n * a published CLI already documents. A composed entry sets one per api alias. */\n envPrefix: string;\n /** The generated instance client. */\n client: Record;\n /** How that client takes its inputs. Defaults to `'grouped'`, the generated default. */\n argsStyle?: 'grouped' | 'flat';\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\ntype CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\ntype CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\ntype CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\ntype CommandSource = {\n namespace?: string;\n commands: Array;\n /** Absent = inherit the first wired source's — a root `login` shares the binary's identity. */\n wiring?: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nfunction groupSlug(group: string): string {\n return group\n .toLowerCase()\n .split(/[^a-z0-9]+/)\n .filter(Boolean)\n .join('-');\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/**\n * The parsed argv as one call input, in the style the wired client takes: grouped by layer\n * (the default) or merged into one object.\n */\nfunction callInputs(\n command: CliCommand,\n positionals: Record,\n params: Record,\n body: unknown,\n argsStyle: CliWiring['argsStyle']\n): Record | undefined {\n const inputs: Record = {};\n if (argsStyle === 'flat') {\n Object.assign(inputs, positionals, params);\n if (body !== undefined) {\n if (command.body?.merged === true) Object.assign(inputs, body as Record);\n else inputs.body = body;\n }\n } else {\n if (Object.keys(positionals).length > 0) inputs.path = positionals;\n if (Object.keys(params).length > 0) inputs.query = params;\n if (body !== undefined) inputs.body = body;\n }\n return Object.keys(inputs).length > 0 ? inputs : undefined;\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nfunction parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n // An untagged operation is only ever addressed by its bare name, so when that name is also\n // a group slug the name wins — reading it as the group would leave the command unreachable.\n // A tagged operation in the same position keeps yielding to group help: it is still\n // reachable as ` `.\n const untagged = commands.some((c) => c.group === undefined && c.name === argv[0]);\n let command: CliCommand | undefined;\n let rest: string[];\n if (!untagged && slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** `cafe-api` → `CAFE_API`: the casing of every credential variable this CLI reads. */\nfunction constantCase(value: string): string {\n return value\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix;\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${constantCase(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n name: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n name,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${name} ${topic} …`, '', 'Commands:']\n : [`Usage: ${name} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${constantCase(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${name} ${grouped ? ' ' : ''} --help for command details; ${name} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nasync function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nasync function runCli(sources: CommandSource[], argv: string[]): Promise;\nasync function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // A source without wiring inherits the first wired one, so the documented root-source\n // shape `{ commands: [login] }` works: the login shares the composed binary's identity.\n const inherited = sources.find((source) => source.wiring !== undefined)?.wiring;\n const wiringOf = (source: CommandSource): CliWiring => source.wiring ?? (inherited as CliWiring);\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = wiringOf(sources[0]);\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.name)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, wiringOf(source), argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, wiringOf(root as CommandSource), argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], name: string): string[] {\n const lines = [`Usage: ${name} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${name} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(\n commands,\n wiring.name,\n wiring.schemes ?? [],\n wiring.envPrefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n // Redaction matches these against header VALUES — so basic auth must contribute the\n // form the wire actually carries (`Basic ${base64(user:pass)}`), not just the raw\n // password, which never appears in the encoded header.\n const basic = auth.basic as { username: string; password: string } | undefined;\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(basic ? [basic.password, btoa(`${basic.username}:${basic.password}`)] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const argument = callInputs(command, positionals, params, body, wiring.argsStyle);\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n // An SSE method returns a lazy stream — drain the stubbed response so its fetch\n // actually runs and captures the request.\n if (command.sse) for await (const _event of result as AsyncIterable);\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}", } as const; export type RuntimeModuleName = keyof typeof RUNTIME_SOURCES; @@ -127,11 +127,11 @@ export const RUNTIME_DECLARED_NAMES = [ 'callInputs', 'coerceResponseHeader', 'commandContract', + 'constantCase', 'createClientCore', 'defaultRetryOn', 'encodeBase64', 'encodeReserved', - 'envPrefix', 'execute', 'groupSlug', 'inputOf', diff --git a/packages/client-generator/src/generators/__tests__/cli.test.ts b/packages/client-generator/src/generators/__tests__/cli.test.ts index d4e241a4f1..9eed3109b0 100644 --- a/packages/client-generator/src/generators/__tests__/cli.test.ts +++ b/packages/client-generator/src/generators/__tests__/cli.test.ts @@ -111,26 +111,17 @@ describe('cliGenerator', () => { }); }); -describe('bin name', () => { - it('folds the TypeScript stem into a command-like name', () => { - // `openapi.client` in a usage line reads as a filename, and yields OPENAPI_CLIENT_* anyway. +describe('naming', () => { + it('fixes the credential prefix to the stem and takes the command name from argv', () => { const out = cliGenerator({ model: MODEL, outputPath: '/out/openapi.client.ts', outputMode: 'single', emit: {}, })[0].content; - expect(out).toContain('binName: "openapi-client"'); - expect(out).not.toContain('binName: "openapi.client"'); - }); - - it('honors an explicit binName', () => { - const out = cliGenerator({ - model: MODEL, - outputPath: '/out/openapi.client.ts', - outputMode: 'single', - emit: { binName: 'cafe' }, - })[0].content; - expect(out).toContain('binName: "cafe"'); + // The prefix is generated, so installing the file under another bin keeps the + // variables; the displayed name follows whatever the operator actually typed. + expect(out).toContain('envPrefix: "OPENAPI_CLIENT"'); + expect(out).toContain('name: basename(process.argv[1] ?? "openapi.client")'); }); }); diff --git a/packages/client-generator/src/generators/__tests__/index.test.ts b/packages/client-generator/src/generators/__tests__/index.test.ts index c7266da974..1f6616cffa 100644 --- a/packages/client-generator/src/generators/__tests__/index.test.ts +++ b/packages/client-generator/src/generators/__tests__/index.test.ts @@ -106,16 +106,13 @@ describe('validateGenerators', () => { const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {}); try { validateGenerators(['python'], { goPackage: 'mypkg' }); - validateGenerators(['go'], { binName: 'cafe-api' }); const messages = warn.mock.calls.map(([message]) => message).join(''); expect(messages).toContain('goPackage is ignored'); - expect(messages).toContain('binName is ignored'); // The generator that reads it is selected, so nothing to say — even alongside // generators that don't read it. warn.mockClear(); - validateGenerators(['typescript', 'zod', 'cli'], { binName: 'cafe-api' }); - validateGenerators(['go'], { goPackage: 'mypkg' }); + validateGenerators(['typescript', 'go'], { goPackage: 'mypkg' }); expect(warn).not.toHaveBeenCalled(); } finally { warn.mockRestore(); diff --git a/packages/client-generator/src/generators/cli/AGENTS.md b/packages/client-generator/src/generators/cli/AGENTS.md index 7494f533c4..90f226c873 100644 --- a/packages/client-generator/src/generators/cli/AGENTS.md +++ b/packages/client-generator/src/generators/cli/AGENTS.md @@ -31,15 +31,15 @@ command with its positionals and flags. A bare operationId resolves to its grouped command when unambiguous. - **Exit codes are a contract:** 0 ok, 1 API error, 2 auth, 3 validation, 4 usage. Errors print ONE JSON object to stderr so stdout stays pipeable. -- **The bin name is a command name, not a filename.** It defaults to the output stem with - dots and other non-word characters folded to `-` (`openapi.client` → `openapi-client`), - because the stem follows the TypeScript file convention and a usage line reading - `openapi.client orders …` looks like a path. `client.binName` overrides it. -- **Credentials come from the environment** — a prefix derived from the bin name - (`CLIENT_TOKEN`), overridable via `wiring.envPrefix` — or explicit flags; `--dry-run` - prints the prepared request with credentials REDACTED. Help lists only the credentials - the description declares, and an unusable `--token` is a usage error, never silently - dropped. +- **The CLI names itself from `process.argv[1]`.** Only the operator's `bin` field decides + what the command is called, so help reads the invoked name back instead of printing a + name from generation that may not exist on the machine. +- **Credentials come from the environment** — `wiring.envPrefix`, the constant-cased output + stem (`CLIENT_TOKEN`), which a composed entry sets per api alias — or explicit flags; + `--dry-run` prints the prepared request with credentials REDACTED. The prefix is fixed at + generation on purpose: a renamed binary must keep reading the variables a published CLI + already documents. Help lists only the credentials the description declares, and an + unusable `--token` is a usage error, never silently dropped. - **Validation is on by default.** The generator declares `requires: ['typescript', 'zod']` and the pipeline pulls prerequisites in automatically, so `--generator cli` alone produces a validating CLI — a user shouldn't have to know which other generator provides it. The diff --git a/packages/client-generator/src/generators/cli/index.ts b/packages/client-generator/src/generators/cli/index.ts index 0410920692..2f63003d0a 100644 --- a/packages/client-generator/src/generators/cli/index.ts +++ b/packages/client-generator/src/generators/cli/index.ts @@ -13,17 +13,6 @@ 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. */ -/** The stem as a command name: dots and other non-word characters fold to `-`. */ -function commandName(stem: string): string { - return ( - stem - .toLowerCase() - .split(/[^a-z0-9]+/) - .filter(Boolean) - .join('-') || 'client' - ); -} - export const cliGenerator: Generator = ({ model, outputPath, emit, selected }) => { const { dir, stem } = anchor(outputPath); const content = renderCliModule(model, { @@ -31,7 +20,6 @@ export const cliGenerator: Generator = ({ model, outputPath, emit, selected }) = importExt: emit.importExt ?? 'js', runtime: emit.runtime ?? 'inline', zodSelected: selected?.includes('zod') ?? false, - binName: emit.binName ?? commandName(stem), pagination: emit.pagination, argsStyle: emit.argsStyle ?? 'grouped', }); @@ -49,7 +37,7 @@ export const cliDocs: Generator = ({ model, outputPath, emit }) => { const content = renderCliDocs(commandData(model, { pagination: emit.pagination }), { title: `${model.title} command-line reference`, frontmatter: emit.docsFrontmatter === true, - binName: emit.binName ?? commandName(stem), + name: stem, schemes: cliAuthSchemes(model), }); return [{ path: join(dir, `${stem}.cli.md`), content }]; diff --git a/packages/client-generator/src/generators/meta.ts b/packages/client-generator/src/generators/meta.ts index f706b3c0e5..51bf2b328d 100644 --- a/packages/client-generator/src/generators/meta.ts +++ b/packages/client-generator/src/generators/meta.ts @@ -125,16 +125,6 @@ export const BUILTIN_META: Record = { }, }; -/** Options a single generator reads, so setting one without it selected is a no-op. */ -const SINGLE_GENERATOR_OPTIONS: { - option: 'binName' | 'goPackage'; - generators: GeneratorName[]; - reason: string; -}[] = [ - { option: 'binName', generators: ['cli'], reason: 'it names the generated command' }, - { option: 'goPackage', generators: ['go'], reason: 'it declares the Go package clause' }, -]; - /** * Validate a generator selection against every selected generator's declared * contract, throwing the first violation with an actionable message. Runs before @@ -150,16 +140,14 @@ export function validateSelection( outputMode?: OutputMode ): void { const selected = new Set(names); - // Options only one generator reads. `notApplicable` can't express this: it fires per - // generator, so marking `binName` on `typescript` would warn on `--generator typescript - // --generator cli`, where `cli` does apply it. Setting one with none of its generators selected + // `goPackage` is read by one generator, which `notApplicable` can't express: it fires + // per generator, so marking the option on `typescript` would warn on `--generator + // typescript --generator go`, where `go` does apply it. Setting it with `go` unselected // does nothing at all, which is worth saying. - for (const { option, generators, reason } of SINGLE_GENERATOR_OPTIONS) { - if (emit[option] !== undefined && !generators.some((generator) => selected.has(generator))) { - logger.warn( - `generate-client: ${option} is ignored — ${reason}, and no selected generator uses it (add --generator ${generators[0]}).\n` - ); - } + if (emit.goPackage !== undefined && !selected.has('go')) { + logger.warn( + 'generate-client: goPackage is ignored — it declares the Go package clause, and no selected generator uses it (add --generator go).\n' + ); } const errorMode = emit.errorMode ?? 'throw'; const dateType = emit.dateType ?? 'string'; diff --git a/packages/client-generator/src/pipeline.ts b/packages/client-generator/src/pipeline.ts index 1792d32c4c..6c5bd083f9 100644 --- a/packages/client-generator/src/pipeline.ts +++ b/packages/client-generator/src/pipeline.ts @@ -242,7 +242,6 @@ export async function generateClient( setup: setupBlock, runtime: options.runtime, importExt: options.importExt, - binName: options.binName, goPackage: options.goPackage, pagination: options.pagination, docs: options.docs, diff --git a/packages/client-generator/src/runtime/__tests__/cli.test.ts b/packages/client-generator/src/runtime/__tests__/cli.test.ts index 1059686321..c907732a51 100644 --- a/packages/client-generator/src/runtime/__tests__/cli.test.ts +++ b/packages/client-generator/src/runtime/__tests__/cli.test.ts @@ -170,7 +170,8 @@ function fakeWiring(overrides: Partial & { results?: Record configured.push(config as Record), schemes: [{ key: 'BearerAuth', kind: 'bearer' }], @@ -201,7 +202,7 @@ describe('custom commands (composition)', () => { expect(code).toBe(0); expect(out).toEqual(['me']); expect(received[0].params).toEqual({ verbose: true }); - expect(received[0].wiring.binName).toBe('cafe'); + expect(received[0].wiring.name).toBe('cafe'); }); it('lists a custom command in help and its declared contract in schema', async () => { @@ -277,7 +278,7 @@ describe('multi-source runCli (one binary, several APIs)', () => { const login: CustomCommand = { name: 'login', handler: (context) => { - seen.push(context.wiring.binName); + seen.push(context.wiring.name); context.wiring.stdout('ok'); return 0; }, @@ -327,8 +328,8 @@ describe('multi-source runCli (one binary, several APIs)', () => { }); }); -describe('wiring.envPrefix', () => { - it('overrides the credential prefix without changing the displayed name', async () => { +describe('wiring.envPrefix (what a composed entry sets per api)', () => { + it('drives the credential variables without changing the displayed name', async () => { const { wiring, out } = fakeWiring({ envPrefix: 'CAFE_SHOP' }); await runCli(COMMANDS, wiring, ['--help']); const help = out.join('\n'); diff --git a/packages/client-generator/src/runtime/cli.ts b/packages/client-generator/src/runtime/cli.ts index 26f753f521..d48ce5c801 100644 --- a/packages/client-generator/src/runtime/cli.ts +++ b/packages/client-generator/src/runtime/cli.ts @@ -54,10 +54,13 @@ export type CliCommand = { export type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' }; export type CliWiring = { - binName: string; - /** Credential variable prefix. Defaults to `binName`, constant-cased — set it when the - * displayed name and the credential family must differ (a composed multi-API binary). */ - envPrefix?: string; + /** 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. */ @@ -348,9 +351,9 @@ export function parseInvocation(commands: CliCommand[], argv: string[]): CliInvo return { kind: 'run', command, positionals, params, globals }; } -/** Credential env-var prefix: bin name constant-cased (`cafe-api` → `CAFE_API`). */ -export function envPrefix(binName: string): string { - return binName +/** `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(); @@ -358,7 +361,7 @@ export function envPrefix(binName: string): string { function resolveAuth(wiring: CliWiring, token: string | undefined): Record { const env = wiring.env ?? {}; - const prefix = wiring.envPrefix ?? envPrefix(wiring.binName); + const prefix = wiring.envPrefix; const auth: Record = {}; for (const scheme of wiring.schemes ?? []) { if (scheme.kind === 'bearer') { @@ -369,7 +372,7 @@ function resolveAuth(wiring: CliWiring, token: string | undefined): Record | undefined), @@ -424,7 +427,7 @@ function commandContract(command: CliCommand): Record { function renderHelp( commands: CliCommand[], - binName: string, + name: string, schemes: CliAuthScheme[], prefix: string, topic?: CliCommand | string @@ -432,7 +435,7 @@ function renderHelp( if (topic !== undefined && typeof topic !== 'string') { const command = topic; const usage = [ - binName, + name, ...(command.group ? [groupSlug(command.group)] : []), command.name, ...command.positionals.map((slot) => `<${slot.name}>`), @@ -465,8 +468,8 @@ function renderHelp( : commands; const lines = typeof topic === 'string' - ? [`Usage: ${binName} ${topic} …`, '', 'Commands:'] - : [`Usage: ${binName} [group] …`, '', 'Commands:']; + ? [`Usage: ${name} ${topic} …`, '', 'Commands:'] + : [`Usage: ${name} [group] …`, '', 'Commands:']; const seenGroups = new Set(); const grouped = commands.some((c) => c.group); for (const command of scope) { @@ -494,7 +497,7 @@ function renderHelp( ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []), ...schemes .filter((scheme) => scheme.kind === 'apiKey') - .map((scheme) => `${prefix}_API_KEY_${envPrefix(scheme.key)}`), + .map((scheme) => `${prefix}_API_KEY_${constantCase(scheme.key)}`), ]; lines.push( '', @@ -508,7 +511,7 @@ function renderHelp( ` --json Request body`, ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []), '', - `Run ${binName} ${grouped ? ' ' : ''} --help for command details; ${binName} schema prints its schemas.` + `Run ${name} ${grouped ? ' ' : ''} --help for command details; ${name} schema prints its schemas.` ); return lines; } @@ -586,7 +589,7 @@ async function runSources(sources: CommandSource[], argv: string[]): Promise candidate.namespace === argv[0]); @@ -610,8 +613,8 @@ async function runSources(sources: CommandSource[], argv: string[]): Promise …`, '', 'APIs:']; +function renderComposedHelp(sources: CommandSource[], name: string): string[] { + const lines = [`Usage: ${name} …`, '', 'APIs:']; for (const source of sources) { if (source.namespace !== undefined) lines.push(` ${source.namespace}`); } @@ -622,7 +625,7 @@ function renderComposedHelp(sources: CommandSource[], binName: string): string[] lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd()); } } - lines.push('', `Run ${binName} --help for that API's commands.`); + lines.push('', `Run ${name} --help for that API's commands.`); return lines; } @@ -653,12 +656,11 @@ async function runSingle( const invocation = parseInvocation(commands, argv); if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message }); if (invocation.kind === 'help') { - const prefix = wiring.envPrefix ?? envPrefix(wiring.binName); for (const line of renderHelp( commands, - wiring.binName, + wiring.name, wiring.schemes ?? [], - prefix, + wiring.envPrefix, invocation.topic )) stdout(line); diff --git a/packages/client-generator/src/types.ts b/packages/client-generator/src/types.ts index 718dc40955..59ef57c853 100644 --- a/packages/client-generator/src/types.ts +++ b/packages/client-generator/src/types.ts @@ -88,8 +88,6 @@ export type GenerateClientOptions = { * `'ts'` suits runtimes that resolve specifiers literally, like Node's built-in * type stripping (`node client.ts`). */ importExt?: 'js' | 'ts'; - /** Command name for the `cli` generator; defaults to the output stem, sanitized. */ - binName?: string; /** Package clause of the `go` generator's output. Defaults to `client`. */ goPackage?: string; /** 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 6d7b0b7723..f8be268d00 100644 --- a/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap +++ b/packages/core/src/__tests__/__snapshots__/redocly-yaml.test.ts.snap @@ -219,9 +219,6 @@ exports[`createConfigTypes > matches snapshot for the default config schema 1`] "grouped", ], }, - "binName": { - "type": "string", - }, "cliOutput": { "type": "string", }, diff --git a/packages/core/src/types/redocly-yaml.ts b/packages/core/src/types/redocly-yaml.ts index cc2e3b5ba8..05a6679447 100644 --- a/packages/core/src/types/redocly-yaml.ts +++ b/packages/core/src/types/redocly-yaml.ts @@ -379,7 +379,6 @@ const Client: NodeType = { outputMode: { enum: ['single', 'split'] }, runtime: { enum: ['inline', 'package'] }, importExt: { enum: ['js', 'ts'] }, - binName: { type: 'string' }, goPackage: { type: 'string' }, cliOutput: { type: 'string' }, errorMode: { enum: ['throw', 'result'] }, diff --git a/tests/e2e/generate-client/cli-compose.test.ts b/tests/e2e/generate-client/cli-compose.test.ts index a7cb48c9e6..118a787a4b 100644 --- a/tests/e2e/generate-client/cli-compose.test.ts +++ b/tests/e2e/generate-client/cli-compose.test.ts @@ -62,9 +62,9 @@ const login: CustomCommand = { process.exit( await runCli( [ - { commands: [login], wiring: { ...shop.wiring, binName: 'cafe' } }, - { namespace: 'shop', commands: shop.COMMANDS, wiring: { ...shop.wiring, binName: 'cafe', envPrefix: 'CAFE_SHOP' } }, - { namespace: 'kitchen', commands: kitchen.COMMANDS, wiring: { ...kitchen.wiring, binName: 'cafe', envPrefix: 'CAFE_KITCHEN' } }, + { commands: [login], wiring: shop.wiring }, + { namespace: 'shop', commands: shop.COMMANDS, wiring: { ...shop.wiring, envPrefix: 'CAFE_SHOP' } }, + { namespace: 'kitchen', commands: kitchen.COMMANDS, wiring: { ...kitchen.wiring, envPrefix: 'CAFE_KITCHEN' } }, ], process.argv.slice(2) ) @@ -155,7 +155,6 @@ describe('config-driven composition (client.cliOutput)', () => { [ 'extends: []', 'client:', - ' binName: cafe', // A directory nothing else creates — the composed entry makes its own. ' cliOutput: ./bin/cafe.ts', ' importExt: ts', @@ -196,7 +195,7 @@ describe('config-driven composition (client.cliOutput)', () => { encoding: 'utf-8', }); expect(help.status, help.stderr).toBe(0); - expect(help.stdout).toContain('Usage: cafe '); + expect(help.stdout).toContain('Usage: cafe.ts '); expect(help.stdout).toContain('shop'); expect(help.stdout).toContain('kitchen'); }); diff --git a/tests/e2e/generate-client/examples/cli/README.md b/tests/e2e/generate-client/examples/cli/README.md index 5cc116e1b4..309cb8112c 100644 --- a/tests/e2e/generate-client/examples/cli/README.md +++ b/tests/e2e/generate-client/examples/cli/README.md @@ -21,7 +21,7 @@ npx tsx src/api/client.cli.ts schema createOrder `--dry-run` prints the prepared request (credentials redacted) without sending it. Credentials come from environment variables derived from the file stem: `CLIENT_TOKEN` for bearer auth here, or pass `--token`. Exit codes are a documented contract (0 ok, 1 API error, 2 auth, 3 validation, 4 usage), and errors print one JSON object to stderr so stdout stays clean for piping. -To ship a real bin, compile with `tsc` and point `package.json`'s `bin` at the compiled file. +To ship a real bin, compile with `tsc` and point `package.json`'s `bin` at the compiled CLI module (`dist/api/client.cli.js`), not at the client beside it. `client.docs: true` (the `--docs` flag) is set here, so the CLI also writes its own reference next to itself: `src/api/client.cli.md` — usage, global flags, credential variables, exit codes, and every command with its arguments and flags. It renders from the same command table the CLI dispatches on, so the page cannot drift from the tool; regenerate and the docs follow. From 090b1c3094de08db86f76620ff82680bc41d0583 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 19 Aug 2026 19:06:54 +0300 Subject: [PATCH 206/211] docs: reduce the changeset to one sentence The entry had grown to eight paragraphs of implementation detail for one feature. The changelog names what a user gets; the guides carry the rest. --- .changeset/agent-friendly-generators.md | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/.changeset/agent-friendly-generators.md b/.changeset/agent-friendly-generators.md index df81fd6346..42ee4195d2 100644 --- a/.changeset/agent-friendly-generators.md +++ b/.changeset/agent-friendly-generators.md @@ -3,24 +3,4 @@ '@redocly/cli': minor --- -Added agent-friendly client generation: `python`, `go`, `php`, and `cli` generators in addition to TypeScript generators. - -Added `--docs` (`client.docs`), which writes the reference documentation for what a run generates: each generator documents itself with one Markdown page next to its output. - -Added composable generated CLIs (custom commands, one binary over several APIs via `client.cliOutput`). -The generated CLI takes its displayed name from the command it is invoked as, so help always names a command that exists, and its credential environment variables come from the output file name (`CAFE_TOKEN` for `cafe.ts`), so installing it under any `bin` name keeps the variables your users set. - -Added language-neutral authoring toolkit with per-generator options, including `client.options.python.models: pydantic`, which emits `BaseModel` classes instead of dataclasses. - -Added an `eject-generator` command that vendors any built-in generator, with its design as an agent skill, into your repo. - -Fixed generated Python, PHP, and Go clients for descriptions that use one parameter name in two locations (`id` in the path and in the query, which OpenAPI permits), or a parameter named after an argument the method declares itself (`body`, `headers`, `timeout`, `params`). The later parameter now takes a suffixed name — `id_2` in Python, `$id2` in PHP, `id2` in Go — and the wire names stay as written, so both values still reach the API. Before this, the generated module did not parse at all: a `SyntaxError` in Python, a fatal redefinition in PHP, and a compile error in Go. - -Renamed pagination operation extension from `x-redocly-pagination` to `x-redoclyPagination`. -The previous name is no longer read. - -**Note**: every generated TypeScript operation now takes ONE input object, and `argsStyle: grouped` is the default. The input groups its values by transport layer — `path`, `query`, `headers`, `cookies`, and `body` as sibling keys — so `updateOrder({ path: { orderId }, body })` replaces the old positional call. `argsStyle: flat` remains, redefined as the same object with the layers merged into one level (`updateOrder({ orderId, ...body })`); it merges the properties of a required object body, and keeps a `body` key for a body it cannot merge. Two smaller consequences: the module-level exports are now bindings of the client's own methods (`export const { updateOrder } = client;`) rather than wrapper functions, so one operation can no longer have two argument shapes; and the query-parameter type alias is `Query` (was `Params`), beside a new `Path`. The compiler points at every call site that needs the edit. - -**Note**: the generated TypeScript client no longer exports per-scheme credential setters (`setBearer`, `setBasicAuth`, `setApiKey`). Set credentials with `configure({ auth: … })` or on the instance with `client.auth.bearer(…)`, `client.auth.basic(…)`, and `client.auth.apiKey('', …)`. One consequence is welcome: a setter name is no longer reserved, so an operation or schema of that name keeps it. - -**Note**: the TypeScript client generator is now selected as `typescript` instead of `sdk`, matching the language-named generators. Update `client.generators` lists and `--generator` flags; the old name fails with a message that points at the rename. +Added agent-friendly client generation: `python`, `go`, `php`, and `cli` generators beside the TypeScript client, each self-documenting with `--docs`, configurable per generator, and available as source in your own repository through `eject-generator`. From 1bdd8ec454e1f1a42432ccc3e73f72131234e922 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 19 Aug 2026 19:07:19 +0300 Subject: [PATCH 207/211] fix(client-generator): make the flat fallback, the python iterators, and cli help honest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five review findings, each reproduced before it was fixed. A flat-style client rejected its own typed call. An operation whose merged names would collide keeps the namespaced input type, but every call still went through `namespaceArgs`, so `{ path, query }` arrived as unknown keys. The descriptor now carries `argsStyle: "grouped"` for exactly those operations, and the runtime and the CLI dispatcher both read it — the type and the wire cannot disagree. The collision check missed an `allOf` body. `mergeBody` accepted an intersection but counted properties only for a plain object, so a parameter sharing a name with an allOf body field produced a merged shape that dropped the value from the body. One recursive walk now collects the names of every member. A python iterator asked for the path template. `build_url` was called with an empty path dict, so a paginated operation under a path parameter requested `/orders/{orderId}/items` literally, and the caller had no argument to pass the value in. The iterators now take the same path arguments as the operation, named through the same namespace, so a name the method moved aside is the same name there. Go and PHP already did this; python now matches them. A flat infinite query emitted `vars.["after-cursor"]`, which does not parse. Member access follows the name's shape in both argument styles. Help named the script file. `basename(process.argv[1])` prints `cafe.cli.js` for a Windows shim, a `node dist/…` run, or `tsx client.cli.ts` — a command nobody can type, which is what reading the invocation was meant to prevent. `invokedName` drops a script or shim extension and the `.cli` marker: a `mycafe` symlink still prints `mycafe`, and running the file prints `cafe`. --- docs/@v2/guides/use-generated-client.md | 1 + .../src/emitters/__tests__/cli.test.ts | 2 +- .../src/emitters/__tests__/operations.test.ts | 26 +++++++ .../emitters/__tests__/tanstack-query.test.ts | 48 ++++++++++++ packages/client-generator/src/emitters/cli.ts | 23 +++++- .../src/emitters/client-assembly.ts | 2 +- .../src/emitters/descriptor.ts | 17 ++++- .../src/emitters/render-client.ts | 36 +++++++-- .../src/emitters/runtime-sources.ts | 13 ++-- .../src/emitters/tanstack-query.ts | 10 ++- .../src/generators/__tests__/cli.test.ts | 5 +- .../src/generators/__tests__/python.test.ts | 53 +++++++++++++ .../src/generators/python/index.ts | 23 ++++-- .../src/runtime/__tests__/cli.test.ts | 18 +++++ .../runtime/__tests__/create-client.test.ts | 28 +++++++ packages/client-generator/src/runtime/cli.ts | 21 ++++- .../src/runtime/create-client.ts | 9 ++- .../client-generator/src/runtime/types.ts | 6 ++ tests/e2e/generate-client/cafe.snapshot.ts | 15 +++- tests/e2e/generate-client/cli-compose.test.ts | 4 +- .../zero-install-quickstart/src/api/client.ts | 15 +++- tests/e2e/generate-client/python.test.ts | 76 +++++++++++++++++++ 22 files changed, 412 insertions(+), 39 deletions(-) diff --git a/docs/@v2/guides/use-generated-client.md b/docs/@v2/guides/use-generated-client.md index 0c112a5773..c878e578bf 100644 --- a/docs/@v2/guides/use-generated-client.md +++ b/docs/@v2/guides/use-generated-client.md @@ -150,6 +150,7 @@ Each api keeps its own server URL, schemes, and credentials. The CLI has no name of its own to configure. It reads the name it was invoked as from the process, so `--help` always shows the command you typed. +When the process starts from the file itself, such as `node dist/cafe.cli.js` or a Windows `bin` shim, the help drops the script extension and shows `cafe`. To type `cafe` instead of `npx tsx src/cafe.ts`, compile the entry and point the `bin` field of your `package.json` at the compiled file. The end of this section shows this step. diff --git a/packages/client-generator/src/emitters/__tests__/cli.test.ts b/packages/client-generator/src/emitters/__tests__/cli.test.ts index ef82d28869..381e6efe2e 100644 --- a/packages/client-generator/src/emitters/__tests__/cli.test.ts +++ b/packages/client-generator/src/emitters/__tests__/cli.test.ts @@ -246,7 +246,7 @@ describe('renderCliModule', () => { 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 { runCli, type CliCommand, type CliWiring } from "@redocly/client-generator";' + 'import { invokedName, runCli, type CliCommand, type CliWiring } from "@redocly/client-generator";' ); expect(out).not.toContain('function parseInvocation'); expect(out).toContain('import { zodValidation } from "./client.zod.js";'); diff --git a/packages/client-generator/src/emitters/__tests__/operations.test.ts b/packages/client-generator/src/emitters/__tests__/operations.test.ts index 359bf30314..2a92dce620 100644 --- a/packages/client-generator/src/emitters/__tests__/operations.test.ts +++ b/packages/client-generator/src/emitters/__tests__/operations.test.ts @@ -206,6 +206,32 @@ describe('call inputs — the merged shape (argsStyle: flat)', () => { expect(out).toContain('body?: PatchThingBody;'); }); + it('counts the properties of an allOf body, which a merged call would spread too', () => { + const out = emitFlat({ + name: 'saveThing', + path: '/things/{id}', + pathParams: [param('id', 'path', true)], + queryParams: [param('label', 'query', false)], + requestBody: { + contentType: 'application/json', + required: true, + schema: { + kind: 'intersection', + members: [ + { kind: 'object', properties: [{ name: 'label', schema: SCALAR, required: true }] }, + { kind: 'object', properties: [{ name: 'note', schema: SCALAR, required: false }] }, + ], + }, + }, + }); + // `label` arrives from the query AND from the body, so the merged shape is impossible. + expect(out).toContain('path: SaveThingPath;'); + expect(out).toContain('query?: SaveThingQuery;'); + expect(out).toContain('body: SaveThingBody;'); + // The descriptor says the same, so the runtime takes the namespaced call. + expect(out).toContain('argsStyle: "grouped"'); + }); + it('falls back to the namespaced shape when one name lands in two layers', () => { const out = emitFlat({ name: 'getThing', 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 b44d5f6509..2233a36ef8 100644 --- a/packages/client-generator/src/emitters/__tests__/tanstack-query.test.ts +++ b/packages/client-generator/src/emitters/__tests__/tanstack-query.test.ts @@ -409,3 +409,51 @@ describe('renderTanstackModule', () => { }); }); }); + +describe('a pagination parameter whose name is not an identifier', () => { + const spec = { + name: 'listOrders', + method: 'get' as const, + path: '/orders', + queryParams: [param('after-cursor', 'query', false)], + successResponses: [ + { + contentType: 'application/json', + status: 200, + schema: { + kind: 'object' as const, + properties: [ + { name: 'items', schema: { kind: 'array' as const, items: SCALAR }, required: true }, + { name: 'next', schema: SCALAR, required: false }, + ], + }, + }, + ], + }; + const pagination: PaginationConfig = { + operations: { + listOrders: { + style: 'cursor', + cursorParam: 'after-cursor', + nextCursor: '/next', + items: '/items', + }, + }, + }; + + it('reads it with bracket access in both argument styles', () => { + const grouped = renderTanstackModule( + apiModel({ services: [{ name: 'Default', operations: [operation(spec)] }] }), + { sdkModule: SDK, framework: 'react', pagination } + ); + expect(grouped).toContain('initialPageParam: vars.query?.["after-cursor"]'); + + const flat = renderTanstackModule( + apiModel({ services: [{ name: 'Default', operations: [operation(spec)] }] }), + { sdkModule: SDK, framework: 'react', pagination, argsStyle: 'flat' } + ); + // `vars.["after-cursor"]` would not even parse. + expect(flat).toContain('initialPageParam: vars["after-cursor"]'); + expect(flat).not.toContain('vars.['); + }); +}); diff --git a/packages/client-generator/src/emitters/cli.ts b/packages/client-generator/src/emitters/cli.ts index e79318e74b..dd34734e7f 100644 --- a/packages/client-generator/src/emitters/cli.ts +++ b/packages/client-generator/src/emitters/cli.ts @@ -80,6 +80,20 @@ function mergedBodyFlag( return 'mergeBody' in shape && shape.mergeBody ? { merged: true } : {}; } +/** + * The operations a flat-style run still addresses by layer: their merged names would + * collide, so the client's own input type keeps the namespaced shape and the dispatcher + * has to build that shape too. + */ +function groupedInputFlag( + op: OperationModel, + model: ApiModel, + argsStyle: 'grouped' | 'flat' | undefined +): { argsStyle?: 'grouped' } { + if (argsStyle !== 'flat') return {}; + return 'collisions' in flatInputShape(op, model.schemas) ? { argsStyle: 'grouped' } : {}; +} + /** Every operation as pure command data — the table `runCli` interprets. */ export function commandData( model: ApiModel, @@ -113,6 +127,7 @@ export function commandData( ...(resolveOperationPagination(op, model, emit.pagination).spec !== undefined ? { paginated: true } : {}), + ...groupedInputFlag(op, model, emit.argsStyle), ...(isSseOp(op) ? { sse: true } : {}), ...(isBlobOp(op) ? { blob: true } : {}), ...(jsonBody !== undefined || responseSchema !== undefined @@ -214,10 +229,12 @@ export function renderCliModule(model: ApiModel, options: CliModuleOptions): str const parts = [ '#!/usr/bin/env node', HEADER, - 'import { readFileSync, realpathSync, writeFileSync } from "node:fs";\nimport { basename } from "node:path";\nimport { fileURLToPath } from "node:url";', + 'import { readFileSync, realpathSync, writeFileSync } from "node:fs";\nimport { fileURLToPath } from "node:url";', [ ...(options.runtime === 'package' - ? ['import { runCli, type CliCommand, type CliWiring } from "@redocly/client-generator";'] + ? [ + 'import { invokedName, runCli, type CliCommand, type CliWiring } from "@redocly/client-generator";', + ] : []), `import { ${clientImports.join(', ')} } from "${clientModule}";`, ...(options.zodSelected @@ -237,7 +254,7 @@ export function renderCliModule(model: ApiModel, options: CliModuleOptions): str ] : []), `export const wiring: CliWiring = { - name: basename(process.argv[1] ?? ${codeJson(options.stem)}), + name: invokedName(process.argv[1], ${codeJson(options.stem)}), envPrefix: ${codeJson(constantCase(options.stem))}, client, ${options.argsStyle === 'flat' ? ' argsStyle: "flat",\n' : ''} configure, diff --git a/packages/client-generator/src/emitters/client-assembly.ts b/packages/client-generator/src/emitters/client-assembly.ts index d6e6b63e18..b47f7c9693 100644 --- a/packages/client-generator/src/emitters/client-assembly.ts +++ b/packages/client-generator/src/emitters/client-assembly.ts @@ -75,7 +75,7 @@ function emitClient( ops.length > 0 ? [ renderOpsType(model, idents, ctx), - renderDescriptors(model, idents, ctx.dateType, pagination), + renderDescriptors(model, idents, ctx.dateType, pagination, ctx.argsStyle), ] : // A spec with no operations still gets the uniform wiring shape. [ diff --git a/packages/client-generator/src/emitters/descriptor.ts b/packages/client-generator/src/emitters/descriptor.ts index d9d8ed649a..85208cb4f0 100644 --- a/packages/client-generator/src/emitters/descriptor.ts +++ b/packages/client-generator/src/emitters/descriptor.ts @@ -13,8 +13,9 @@ import { import type { SecuritySpec } from '../runtime/types.js'; import { uniqueIdent } from './identifier.js'; import { isTypedMultipart } from './operation-types.js'; +import type { ArgsStyle } from './operations.js'; import type { ModelPagination } from './pagination.js'; -import { responseText } from './render-client.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'; @@ -41,7 +42,8 @@ function descriptorValue( schemes: SecuritySchemeModel[], dateType: DateType, pagination?: ModelPagination, - schemas: readonly NamedSchemaModel[] = [] + schemas: readonly NamedSchemaModel[] = [], + argsStyle: ArgsStyle = 'grouped' ) { const params = [...op.pathParams, ...op.queryParams, ...op.headerParams, ...op.cookieParams].map( (p) => ({ @@ -94,6 +96,12 @@ function descriptorValue( ...(responseHeaders === undefined ? {} : { responseHeaders }), // The resolved spec is already normalized with stable key order (see pagination.ts). ...(pagination?.has(op.name) ? { pagination: pagination.get(op.name)!.spec } : {}), + // A merged call cannot carry one name for two layers, so an operation whose names + // collide keeps the namespaced shape — its `Variables` says so, and the runtime + // has to agree or the typed call would be rejected. + ...(argsStyle === 'flat' && 'collisions' in flatInputShape(op, schemas) + ? { argsStyle: 'grouped' } + : {}), }; } @@ -102,13 +110,14 @@ export function renderDescriptors( model: ApiModel, idents: Map, dateType: DateType, - pagination?: ModelPagination + pagination?: ModelPagination, + argsStyle: ArgsStyle = 'grouped' ): string { const ops = allOperations(model.services); if (ops.length === 0) return ''; const entryLines = ops.map((op, index) => { const value = codeLiteral( - descriptorValue(op, model.securitySchemes, dateType, pagination, model.schemas) + descriptorValue(op, model.securitySchemes, dateType, pagination, model.schemas, argsStyle) ); return ` ${idents.get(op.name)!}: ${value}${index === ops.length - 1 ? '' : ','}`; }); diff --git a/packages/client-generator/src/emitters/render-client.ts b/packages/client-generator/src/emitters/render-client.ts index cc1cba5323..00ab2884e8 100644 --- a/packages/client-generator/src/emitters/render-client.ts +++ b/packages/client-generator/src/emitters/render-client.ts @@ -137,6 +137,27 @@ function resolvedSchema( return current; } +/** + * The property names of a body a merged call spreads. An `allOf` composition contributes the + * names of every member, because that is what the merged object ends up carrying; a member + * that is not an object makes the whole body unspreadable. + */ +function mergedBodyProperties( + schema: SchemaModel, + schemas: readonly NamedSchemaModel[] | undefined +): string[] | undefined { + const resolved = resolvedSchema(schema, schemas); + if (resolved?.kind === 'object') return resolved.properties.map((property) => property.name); + if (resolved?.kind !== 'intersection') return undefined; + const names: string[] = []; + for (const member of resolved.members) { + const memberNames = mergedBodyProperties(member, schemas); + if (memberNames === undefined) return undefined; + names.push(...memberNames); + } + return names; +} + /** * How a flat-style call spells one operation's inputs. Every parameter sits at one level, * and a REQUIRED object body contributes its own properties — an optional body cannot @@ -157,18 +178,17 @@ export function flatInputShape( ...op.headerParams, ...op.cookieParams, ]; - const resolved = op.requestBody ? resolvedSchema(op.requestBody.schema, schemas) : undefined; - const mergeBody = - (op.requestBody?.required ?? false) && - (resolved?.kind === 'object' || resolved?.kind === 'intersection'); + const bodyProperties = + (op.requestBody?.required ?? false) && op.requestBody !== undefined + ? mergedBodyProperties(op.requestBody.schema, schemas) + : undefined; + const mergeBody = bodyProperties !== undefined; const counts = new Map(); for (const param of params) counts.set(param.name, (counts.get(param.name) ?? 0) + 1); // An unmerged body keeps the `body` key, which a parameter of that name would shadow. if (op.requestBody && !mergeBody) counts.set('body', (counts.get('body') ?? 0) + 1); - if (mergeBody && resolved.kind === 'object') { - for (const property of resolved.properties) { - counts.set(property.name, (counts.get(property.name) ?? 0) + 1); - } + for (const property of bodyProperties ?? []) { + counts.set(property, (counts.get(property) ?? 0) + 1); } const collisions = [...counts].filter(([, count]) => count > 1).map(([paramName]) => paramName); return collisions.length > 0 ? { collisions } : { mergeBody }; diff --git a/packages/client-generator/src/emitters/runtime-sources.ts b/packages/client-generator/src/emitters/runtime-sources.ts index 4affa02283..c0e746d620 100644 --- a/packages/client-generator/src/emitters/runtime-sources.ts +++ b/packages/client-generator/src/emitters/runtime-sources.ts @@ -1,7 +1,7 @@ // GENERATED by scripts/generate-runtime-sources.mjs — do not edit. Regenerated on install (`prepare`); manually: `npm run prepare -w @redocly/client-generator`. export const RUNTIME_SOURCES = { 'types.ts': - "/**\n * The public type surface of the client runtime — `@redocly/client-generator`'s\n * app-facing runtime module. Pure types, no runtime code (excluded from coverage).\n * The generator emits `OPERATIONS` literals typed\n * `satisfies Record` against this module, so an\n * incompatible runtime/generated pair fails the consumer's build (the semver skew guard).\n */\n\n/** How one operation parameter is sent: its location plus OpenAPI query-serialization hints. */\nexport type ParamSpec = {\n name: string;\n in: 'path' | 'query' | 'header' | 'cookie';\n style?: 'form' | 'spaceDelimited' | 'pipeDelimited' | 'deepObject';\n explode?: boolean;\n allowReserved?: boolean;\n};\n\n/** One security scheme, denormalized onto the operation (`scheme` names the spec's scheme). */\nexport type SecuritySpec =\n | { scheme: string; kind: 'bearer' | 'basic' }\n | { scheme: string; kind: 'apiKey'; name: string; in: 'header' | 'query' | 'cookie' };\n\n/**\n * How to auto-iterate a paginated operation (drives its `.pages()`/`.items()` members).\n * `nextCursor` and `items` are RFC 6901 JSON pointers into the page (response) value.\n */\nexport type PaginationSpec =\n | {\n style: 'cursor';\n /** The query param the iterator advances with the response's cursor. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the next cursor in the page. */\n nextCursor: string;\n /** Optional pointer to a boolean \"more pages\" flag — `false` stops iteration. */\n hasMore?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n style: 'offset' | 'page';\n /** The numeric query param the iterator advances. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n /** RFC 8288: follow the response's `Link` header `rel=\"next\"`; stop when absent. */\n style: 'link';\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n };\n\n/** The frozen data contract between generated code and the runtime: one operation's wire shape. */\nexport type OperationDescriptor = {\n id: string;\n method: string;\n path: string;\n tags?: readonly string[];\n params?: readonly ParamSpec[];\n /** `multipart: true` marks a typed object body serialized to FormData by the runtime. */\n body?: { contentType: string; multipart?: boolean };\n /** Defaults to `'json'` (content-type negotiation on parse). */\n responseKind?: 'json' | 'text' | 'blob' | 'void' | 'sse';\n sseDataKind?: 'json' | 'text';\n /** OR-alternatives, each an AND-set: the runtime applies the first fully-configured one. */\n security?: readonly (readonly SecuritySpec[])[];\n pagination?: PaginationSpec;\n /**\n * Declared success-response headers for throw-mode `{ envelope: true }`.\n * `name` is the lowercased wire name; `key` is the camelCase envelope property.\n */\n responseHeaders?: readonly ResponseHeaderSpec[];\n};\n\n/** One declared response header the runtime coerces into the envelope `headers` object. */\nexport type ResponseHeaderSpec = {\n name: string;\n key: string;\n type: 'string' | 'number' | 'boolean';\n};\n\n/** A query value: scalars, arrays of scalars, or objects (serialized as deepObject brackets). */\nexport type QueryValue =\n | string\n | number\n | boolean\n | null\n | undefined\n | Array\n | Record;\n\n/** A credential: a literal, or a (possibly async) function resolved per request (refresh flows). */\nexport type TokenProvider = string | (() => string | Promise);\n\n/** Per-instance credentials, keyed by the scheme kinds the runtime can inject. */\nexport type AuthCredentials = {\n bearer?: TokenProvider;\n basic?: { username: string; password: string };\n apiKey?: Record;\n};\n\n/** Backoff shape: 'fixed' = constant delay; 'exponential' = doubling per attempt. */\nexport type RetryStrategy = 'fixed' | 'exponential';\n\n/**\n * The operation's identity, exposed to middleware for targeting (`ctx.operation`).\n * Generated clients instantiate the type parameters with the spec's literal unions\n * (`OperationId`/`OperationPath`/`OperationTag`) so a misspelled operation id in a\n * middleware comparison fails to compile; the string defaults keep every\n * spec-independent consumer (`runtime-contract.ts`, the runtime internals) working\n * with the base shape. `tags` stays mutable (`Tag[]`) so setup-contract types\n * (byte-locked to generated output) remain assignable through middleware callbacks.\n */\nexport type OperationContext<\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n> = { id: Id; path: Path; tags: Tag[] };\n\n/** The mutable request context threaded through the middleware chain. */\nexport type RequestContext = {\n url: string;\n method: string;\n headers: Record;\n body?: unknown;\n operation: Op;\n};\n\n/** The failed attempt handed to a custom `retryOn`: exactly one of `response`/`error` is set. */\nexport type RetryContext = {\n attempt: number;\n request: RequestContext;\n response?: Response;\n error?: unknown;\n};\n\n/** Opt-in retry policy; a per-call override merges field-by-field over the config policy. */\nexport type RetryConfig = {\n retries?: number;\n retryDelay?: number;\n retryStrategy?: RetryStrategy;\n jitter?: boolean;\n retryOn?: (ctx: RetryContext) => boolean | Promise;\n};\n\n/**\n * Structural stand-in for the runtime's ApiError so this module stays import-free\n * (pure types); the real `ApiError` class is assignable to it.\n */\nexport type ApiErrorLike = globalThis.Error & {\n url: string;\n status: number;\n statusText: string;\n body: unknown;\n};\n\n/** One interceptor: any subset of the three hooks. */\nexport type Middleware = {\n onRequest?: (ctx: RequestContext) => void | Promise;\n onResponse?: (\n response: Response,\n ctx: RequestContext\n ) => Response | void | Promise;\n /** Throw mode only: may map/replace the error. */\n // `globalThis.Error` so a spec schema named `Error` cannot shadow it in inline mode.\n onError?: (\n error: ApiErrorLike,\n ctx: RequestContext\n ) => globalThis.Error | Promise;\n};\n\n/** Client configuration: transport, defaults, retry policy, middleware, and credentials. */\nexport type ClientConfig = {\n serverUrl?: string;\n fetch?: typeof fetch;\n headers?:\n | Record\n | (() => Record | Promise>);\n retry?: RetryConfig;\n /** Milliseconds before a request attempt aborts (covers the body read too; each retry\n * attempt gets a fresh budget). Per-call `timeout` overrides it, `0` disables it.\n * SSE streams are long-lived by design and never inherit this value. */\n timeout?: number;\n /** Send an `Idempotency-Key` header on POST/PATCH (one stable key per logical call,\n * reused across retry attempts) — which also makes those retries safe under the\n * default retry policy. `true` generates a UUID per call; a function supplies the key. */\n idempotencyKey?: boolean | (() => string);\n /** Identifies this client to the API via an `X-Redocly-Client` header (the generator\n * bakes a default). Sent only OUTSIDE browsers — a custom header would force a CORS\n * preflight. Override with your own value, or `false` to disable. */\n clientHeader?: string | false;\n middleware?: Middleware[];\n auth?: AuthCredentials;\n /** Fixed at generate time by the generator (`'throw'` when omitted); `configure()` ignores it. */\n errorMode?: 'throw' | 'result';\n /**\n * How each call spells its inputs: `'grouped'` (the default) namespaces them by layer —\n * `{ path, query, headers, cookies, body }` — and `'flat'` takes one merged object.\n * Fixed at generate time, like `errorMode`, because it shapes the static types.\n */\n argsStyle?: 'grouped' | 'flat';\n onRequest?: Middleware['onRequest'];\n onResponse?: Middleware['onResponse'];\n onError?: Middleware['onError'];\n};\n\n/** Response readers for the per-call `parseAs` override. */\nexport type ParseAs = 'auto' | 'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData' | 'stream';\n\n/** Per-call options: standard `RequestInit` plus a retry override, a timeout override\n * (`0` disables the config default), and a forced reader. */\nexport type RequestOptions = RequestInit & {\n retry?: RetryConfig;\n timeout?: number;\n /** Per-call idempotency key: a literal key, `true` to generate one, `false` to skip. */\n idempotencyKey?: string | boolean | (() => string);\n parseAs?: ParseAs;\n /**\n * Throw mode only: return `{ data, headers, response }` instead of the parsed body;\n * ignored in result mode. The explicit `| undefined` keeps the wrappers' emitted\n * `envelope: undefined` strip legal under `exactOptionalPropertyTypes`.\n */\n envelope?: boolean | undefined;\n};\n\n/** Throw-mode success envelope when `RequestOptions.envelope` is `true`. */\nexport type Envelope> = {\n data: TData;\n headers: THeaders;\n response: Response;\n};\n\n/** Per-call options for an SSE stream; reconnect defaults to true. */\nexport type SseOptions = RequestInit & { reconnect?: boolean; reconnectDelay?: number };\n\n/** A single decoded Server-Sent Event with its payload typed from the spec. */\nexport type ServerSentEvent = { event?: string; data: T; id?: string; retry?: number };\n\n/** Result-mode return shape: exactly one of `data`/`error` is set. */\nexport type Result =\n | { data: TData; error: undefined; response: Response }\n | { data: undefined; error: TError; response: Response };\n\n/**\n * The generated `Ops` type's shape: per-operation args/result, plus `kind: 'sse'` for\n * streams and, for paginated operations, `item` (the page's element type) and — on\n * result-mode clients only — `page` (the RAW page type `.pages()` yields, since\n * iteration unwraps the `Result` envelope the one-shot `result` carries).\n */\nexport type OpsShape = Record<\n string,\n {\n args: object;\n result: unknown;\n kind?: 'sse';\n item?: unknown;\n page?: unknown;\n /** Declared success-response headers for `{ envelope: true }` (camelCase keys). */\n headers?: object;\n /** Result-mode entries ignore the throw-only `envelope` option. */\n mode?: 'result';\n }\n>;\n\n/** The always-present client members (assigned after the operation loop — they win collisions). */\nexport type ClientCore = {\n /** Merge into the config; note `middleware` REPLACES the chain (use `use()` to compose). */\n configure(config: ClientConfig): void;\n /** Append interceptors (composes with baked/publisher middleware). */\n use(...middleware: Middleware[]): void;\n auth: {\n bearer(token: TokenProvider): void;\n basic(username: string, password: string): void;\n apiKey(scheme: string, value: TokenProvider): void;\n };\n};\n\n/**\n * The standard TypeScript optionality probe: `{}` has no required members, so\n * `{} extends A` is true exactly when every member of `A` is optional.\n */\n// oxlint-disable-next-line typescript/no-empty-object-type\ntype NoRequiredKeys
= {} extends A ? true : false;\n\n/**\n * The page type `.pages()` yields: the RAW page declared by `page` (the generator\n * writes it only on result-mode paginated entries, whose `result` is the envelope),\n * or the method's own `result` (throw mode — already the raw page).\n */\ntype PageOf = Entry extends { page: unknown }\n ? Entry['page']\n : Entry['result'];\n\n/**\n * The auto-pagination members intersected onto a paginated method — present exactly when\n * the Ops entry declares `item` (the generator writes it only for paginated operations).\n * Args optionality mirrors the method's own; `unknown` otherwise (identity under `&`).\n * Iteration is error-mode-agnostic: `.pages()`/`.items()` yield raw pages/items, and a\n * failed page aborts iteration by throwing `ApiError`, even on result-mode clients; the\n * `onError` middleware hook (throw-mode-only) is not invoked.\n */\ntype Paginated = 'item' extends keyof Entry\n ? NoRequiredKeys extends true\n ? {\n pages(args?: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args?: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : {\n pages(args: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : unknown;\n\n/**\n * The stable identity every client method carries: the SPEC operationId (also set as\n * `fn.name`, but `operationId` is the explicit, minification-proof form) — a robust\n * cache key for consumer wrappers (react-query keys and the like).\n */\nexport type OperationMethodIdentity = { readonly operationId: string };\n\n/** Declared response-header bag for an Ops entry; empty object when none are declared. */\ntype HeadersOf = 'headers' extends keyof Entry\n ? NonNullable\n : Record;\n\n/**\n * Return type of a throw-mode call: the body by default, `Envelope<…>` for a literal\n * `envelope: true`, their union when the flag is a widened `boolean`. Exact\n * `RequestOptions` stays the body — pre-envelope package-mode flat sugar typed every\n * `init` parameter as `RequestOptions`, and widening that would break upgrades without\n * a regenerate. The `keyof` presence gate keeps `{ headers }` / `{ signal }` as the body\n * (`TInit['envelope']` through `TInit & RequestOptions` would otherwise be\n * `boolean | undefined`).\n */\nexport type EnvelopeResult<\n TData,\n THeaders,\n TInit extends RequestOptions | undefined,\n> = TInit extends undefined\n ? TData\n : RequestOptions extends TInit\n ? TInit extends RequestOptions\n ? TData\n : EnvelopeResultForKnownInit\n : EnvelopeResultForKnownInit;\n\ntype EnvelopeResultForKnownInit = 'envelope' extends keyof TInit\n ? [TInit['envelope' & keyof TInit]] extends [true]\n ? Envelope\n : [TInit['envelope' & keyof TInit]] extends [false | undefined]\n ? TData\n : TData | Envelope\n : TData;\n\n/** A one-shot method whose return shape never varies with per-call options. */\ntype BodyMethod =\n NoRequiredKeys extends true\n ? (args?: Entry['args'], init?: RequestOptions) => Promise\n : (args: Entry['args'], init?: RequestOptions) => Promise;\n\n/**\n * One-shot (non-SSE) method: default returns the body; `{ envelope: true }` returns\n * `{ data, headers, response }` with typed declared headers.\n */\ntype ThrowMethod =\n NoRequiredKeys extends true\n ? (\n args?: Entry['args'],\n init?: Init\n ) => Promise, Init>>\n : (\n args: Entry['args'],\n init?: Init\n ) => Promise, Init>>;\n\n/** The typed instance client: one bound method per operation plus the core members. */\nexport type Client = {\n [K in keyof Ops]: Ops[K] extends { kind: 'sse' }\n ? (NoRequiredKeys extends true\n ? (\n args?: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>\n : (\n args: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>) &\n OperationMethodIdentity\n : (Ops[K] extends { mode: 'result' } ? BodyMethod : ThrowMethod) &\n OperationMethodIdentity &\n Paginated;\n} & ClientCore;\n", + "/**\n * The public type surface of the client runtime — `@redocly/client-generator`'s\n * app-facing runtime module. Pure types, no runtime code (excluded from coverage).\n * The generator emits `OPERATIONS` literals typed\n * `satisfies Record` against this module, so an\n * incompatible runtime/generated pair fails the consumer's build (the semver skew guard).\n */\n\n/** How one operation parameter is sent: its location plus OpenAPI query-serialization hints. */\nexport type ParamSpec = {\n name: string;\n in: 'path' | 'query' | 'header' | 'cookie';\n style?: 'form' | 'spaceDelimited' | 'pipeDelimited' | 'deepObject';\n explode?: boolean;\n allowReserved?: boolean;\n};\n\n/** One security scheme, denormalized onto the operation (`scheme` names the spec's scheme). */\nexport type SecuritySpec =\n | { scheme: string; kind: 'bearer' | 'basic' }\n | { scheme: string; kind: 'apiKey'; name: string; in: 'header' | 'query' | 'cookie' };\n\n/**\n * How to auto-iterate a paginated operation (drives its `.pages()`/`.items()` members).\n * `nextCursor` and `items` are RFC 6901 JSON pointers into the page (response) value.\n */\nexport type PaginationSpec =\n | {\n style: 'cursor';\n /** The query param the iterator advances with the response's cursor. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the next cursor in the page. */\n nextCursor: string;\n /** Optional pointer to a boolean \"more pages\" flag — `false` stops iteration. */\n hasMore?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n style: 'offset' | 'page';\n /** The numeric query param the iterator advances. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n /** RFC 8288: follow the response's `Link` header `rel=\"next\"`; stop when absent. */\n style: 'link';\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n };\n\n/** The frozen data contract between generated code and the runtime: one operation's wire shape. */\nexport type OperationDescriptor = {\n id: string;\n method: string;\n path: string;\n tags?: readonly string[];\n params?: readonly ParamSpec[];\n /** `multipart: true` marks a typed object body serialized to FormData by the runtime. */\n body?: { contentType: string; multipart?: boolean };\n /** Defaults to `'json'` (content-type negotiation on parse). */\n responseKind?: 'json' | 'text' | 'blob' | 'void' | 'sse';\n sseDataKind?: 'json' | 'text';\n /** OR-alternatives, each an AND-set: the runtime applies the first fully-configured one. */\n security?: readonly (readonly SecuritySpec[])[];\n pagination?: PaginationSpec;\n /**\n * `'grouped'` marks an operation that takes its inputs namespaced by layer even on a\n * `argsStyle: 'flat'` client — the generator sets it where a merged call could not carry\n * one name for two layers, and the operation's own input type says the same.\n */\n argsStyle?: 'grouped';\n /**\n * Declared success-response headers for throw-mode `{ envelope: true }`.\n * `name` is the lowercased wire name; `key` is the camelCase envelope property.\n */\n responseHeaders?: readonly ResponseHeaderSpec[];\n};\n\n/** One declared response header the runtime coerces into the envelope `headers` object. */\nexport type ResponseHeaderSpec = {\n name: string;\n key: string;\n type: 'string' | 'number' | 'boolean';\n};\n\n/** A query value: scalars, arrays of scalars, or objects (serialized as deepObject brackets). */\nexport type QueryValue =\n | string\n | number\n | boolean\n | null\n | undefined\n | Array\n | Record;\n\n/** A credential: a literal, or a (possibly async) function resolved per request (refresh flows). */\nexport type TokenProvider = string | (() => string | Promise);\n\n/** Per-instance credentials, keyed by the scheme kinds the runtime can inject. */\nexport type AuthCredentials = {\n bearer?: TokenProvider;\n basic?: { username: string; password: string };\n apiKey?: Record;\n};\n\n/** Backoff shape: 'fixed' = constant delay; 'exponential' = doubling per attempt. */\nexport type RetryStrategy = 'fixed' | 'exponential';\n\n/**\n * The operation's identity, exposed to middleware for targeting (`ctx.operation`).\n * Generated clients instantiate the type parameters with the spec's literal unions\n * (`OperationId`/`OperationPath`/`OperationTag`) so a misspelled operation id in a\n * middleware comparison fails to compile; the string defaults keep every\n * spec-independent consumer (`runtime-contract.ts`, the runtime internals) working\n * with the base shape. `tags` stays mutable (`Tag[]`) so setup-contract types\n * (byte-locked to generated output) remain assignable through middleware callbacks.\n */\nexport type OperationContext<\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n> = { id: Id; path: Path; tags: Tag[] };\n\n/** The mutable request context threaded through the middleware chain. */\nexport type RequestContext = {\n url: string;\n method: string;\n headers: Record;\n body?: unknown;\n operation: Op;\n};\n\n/** The failed attempt handed to a custom `retryOn`: exactly one of `response`/`error` is set. */\nexport type RetryContext = {\n attempt: number;\n request: RequestContext;\n response?: Response;\n error?: unknown;\n};\n\n/** Opt-in retry policy; a per-call override merges field-by-field over the config policy. */\nexport type RetryConfig = {\n retries?: number;\n retryDelay?: number;\n retryStrategy?: RetryStrategy;\n jitter?: boolean;\n retryOn?: (ctx: RetryContext) => boolean | Promise;\n};\n\n/**\n * Structural stand-in for the runtime's ApiError so this module stays import-free\n * (pure types); the real `ApiError` class is assignable to it.\n */\nexport type ApiErrorLike = globalThis.Error & {\n url: string;\n status: number;\n statusText: string;\n body: unknown;\n};\n\n/** One interceptor: any subset of the three hooks. */\nexport type Middleware = {\n onRequest?: (ctx: RequestContext) => void | Promise;\n onResponse?: (\n response: Response,\n ctx: RequestContext\n ) => Response | void | Promise;\n /** Throw mode only: may map/replace the error. */\n // `globalThis.Error` so a spec schema named `Error` cannot shadow it in inline mode.\n onError?: (\n error: ApiErrorLike,\n ctx: RequestContext\n ) => globalThis.Error | Promise;\n};\n\n/** Client configuration: transport, defaults, retry policy, middleware, and credentials. */\nexport type ClientConfig = {\n serverUrl?: string;\n fetch?: typeof fetch;\n headers?:\n | Record\n | (() => Record | Promise>);\n retry?: RetryConfig;\n /** Milliseconds before a request attempt aborts (covers the body read too; each retry\n * attempt gets a fresh budget). Per-call `timeout` overrides it, `0` disables it.\n * SSE streams are long-lived by design and never inherit this value. */\n timeout?: number;\n /** Send an `Idempotency-Key` header on POST/PATCH (one stable key per logical call,\n * reused across retry attempts) — which also makes those retries safe under the\n * default retry policy. `true` generates a UUID per call; a function supplies the key. */\n idempotencyKey?: boolean | (() => string);\n /** Identifies this client to the API via an `X-Redocly-Client` header (the generator\n * bakes a default). Sent only OUTSIDE browsers — a custom header would force a CORS\n * preflight. Override with your own value, or `false` to disable. */\n clientHeader?: string | false;\n middleware?: Middleware[];\n auth?: AuthCredentials;\n /** Fixed at generate time by the generator (`'throw'` when omitted); `configure()` ignores it. */\n errorMode?: 'throw' | 'result';\n /**\n * How each call spells its inputs: `'grouped'` (the default) namespaces them by layer —\n * `{ path, query, headers, cookies, body }` — and `'flat'` takes one merged object.\n * Fixed at generate time, like `errorMode`, because it shapes the static types.\n */\n argsStyle?: 'grouped' | 'flat';\n onRequest?: Middleware['onRequest'];\n onResponse?: Middleware['onResponse'];\n onError?: Middleware['onError'];\n};\n\n/** Response readers for the per-call `parseAs` override. */\nexport type ParseAs = 'auto' | 'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData' | 'stream';\n\n/** Per-call options: standard `RequestInit` plus a retry override, a timeout override\n * (`0` disables the config default), and a forced reader. */\nexport type RequestOptions = RequestInit & {\n retry?: RetryConfig;\n timeout?: number;\n /** Per-call idempotency key: a literal key, `true` to generate one, `false` to skip. */\n idempotencyKey?: string | boolean | (() => string);\n parseAs?: ParseAs;\n /**\n * Throw mode only: return `{ data, headers, response }` instead of the parsed body;\n * ignored in result mode. The explicit `| undefined` keeps the wrappers' emitted\n * `envelope: undefined` strip legal under `exactOptionalPropertyTypes`.\n */\n envelope?: boolean | undefined;\n};\n\n/** Throw-mode success envelope when `RequestOptions.envelope` is `true`. */\nexport type Envelope> = {\n data: TData;\n headers: THeaders;\n response: Response;\n};\n\n/** Per-call options for an SSE stream; reconnect defaults to true. */\nexport type SseOptions = RequestInit & { reconnect?: boolean; reconnectDelay?: number };\n\n/** A single decoded Server-Sent Event with its payload typed from the spec. */\nexport type ServerSentEvent = { event?: string; data: T; id?: string; retry?: number };\n\n/** Result-mode return shape: exactly one of `data`/`error` is set. */\nexport type Result =\n | { data: TData; error: undefined; response: Response }\n | { data: undefined; error: TError; response: Response };\n\n/**\n * The generated `Ops` type's shape: per-operation args/result, plus `kind: 'sse'` for\n * streams and, for paginated operations, `item` (the page's element type) and — on\n * result-mode clients only — `page` (the RAW page type `.pages()` yields, since\n * iteration unwraps the `Result` envelope the one-shot `result` carries).\n */\nexport type OpsShape = Record<\n string,\n {\n args: object;\n result: unknown;\n kind?: 'sse';\n item?: unknown;\n page?: unknown;\n /** Declared success-response headers for `{ envelope: true }` (camelCase keys). */\n headers?: object;\n /** Result-mode entries ignore the throw-only `envelope` option. */\n mode?: 'result';\n }\n>;\n\n/** The always-present client members (assigned after the operation loop — they win collisions). */\nexport type ClientCore = {\n /** Merge into the config; note `middleware` REPLACES the chain (use `use()` to compose). */\n configure(config: ClientConfig): void;\n /** Append interceptors (composes with baked/publisher middleware). */\n use(...middleware: Middleware[]): void;\n auth: {\n bearer(token: TokenProvider): void;\n basic(username: string, password: string): void;\n apiKey(scheme: string, value: TokenProvider): void;\n };\n};\n\n/**\n * The standard TypeScript optionality probe: `{}` has no required members, so\n * `{} extends A` is true exactly when every member of `A` is optional.\n */\n// oxlint-disable-next-line typescript/no-empty-object-type\ntype NoRequiredKeys = {} extends A ? true : false;\n\n/**\n * The page type `.pages()` yields: the RAW page declared by `page` (the generator\n * writes it only on result-mode paginated entries, whose `result` is the envelope),\n * or the method's own `result` (throw mode — already the raw page).\n */\ntype PageOf = Entry extends { page: unknown }\n ? Entry['page']\n : Entry['result'];\n\n/**\n * The auto-pagination members intersected onto a paginated method — present exactly when\n * the Ops entry declares `item` (the generator writes it only for paginated operations).\n * Args optionality mirrors the method's own; `unknown` otherwise (identity under `&`).\n * Iteration is error-mode-agnostic: `.pages()`/`.items()` yield raw pages/items, and a\n * failed page aborts iteration by throwing `ApiError`, even on result-mode clients; the\n * `onError` middleware hook (throw-mode-only) is not invoked.\n */\ntype Paginated = 'item' extends keyof Entry\n ? NoRequiredKeys extends true\n ? {\n pages(args?: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args?: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : {\n pages(args: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : unknown;\n\n/**\n * The stable identity every client method carries: the SPEC operationId (also set as\n * `fn.name`, but `operationId` is the explicit, minification-proof form) — a robust\n * cache key for consumer wrappers (react-query keys and the like).\n */\nexport type OperationMethodIdentity = { readonly operationId: string };\n\n/** Declared response-header bag for an Ops entry; empty object when none are declared. */\ntype HeadersOf = 'headers' extends keyof Entry\n ? NonNullable\n : Record;\n\n/**\n * Return type of a throw-mode call: the body by default, `Envelope<…>` for a literal\n * `envelope: true`, their union when the flag is a widened `boolean`. Exact\n * `RequestOptions` stays the body — pre-envelope package-mode flat sugar typed every\n * `init` parameter as `RequestOptions`, and widening that would break upgrades without\n * a regenerate. The `keyof` presence gate keeps `{ headers }` / `{ signal }` as the body\n * (`TInit['envelope']` through `TInit & RequestOptions` would otherwise be\n * `boolean | undefined`).\n */\nexport type EnvelopeResult<\n TData,\n THeaders,\n TInit extends RequestOptions | undefined,\n> = TInit extends undefined\n ? TData\n : RequestOptions extends TInit\n ? TInit extends RequestOptions\n ? TData\n : EnvelopeResultForKnownInit\n : EnvelopeResultForKnownInit;\n\ntype EnvelopeResultForKnownInit = 'envelope' extends keyof TInit\n ? [TInit['envelope' & keyof TInit]] extends [true]\n ? Envelope\n : [TInit['envelope' & keyof TInit]] extends [false | undefined]\n ? TData\n : TData | Envelope\n : TData;\n\n/** A one-shot method whose return shape never varies with per-call options. */\ntype BodyMethod =\n NoRequiredKeys extends true\n ? (args?: Entry['args'], init?: RequestOptions) => Promise\n : (args: Entry['args'], init?: RequestOptions) => Promise;\n\n/**\n * One-shot (non-SSE) method: default returns the body; `{ envelope: true }` returns\n * `{ data, headers, response }` with typed declared headers.\n */\ntype ThrowMethod =\n NoRequiredKeys extends true\n ? (\n args?: Entry['args'],\n init?: Init\n ) => Promise, Init>>\n : (\n args: Entry['args'],\n init?: Init\n ) => Promise, Init>>;\n\n/** The typed instance client: one bound method per operation plus the core members. */\nexport type Client = {\n [K in keyof Ops]: Ops[K] extends { kind: 'sse' }\n ? (NoRequiredKeys extends true\n ? (\n args?: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>\n : (\n args: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>) &\n OperationMethodIdentity\n : (Ops[K] extends { mode: 'result' } ? BodyMethod : ThrowMethod) &\n OperationMethodIdentity &\n Paginated;\n} & ClientCore;\n", 'errors.ts': "/** The error thrown (throw mode) for a non-2xx response, carrying the decoded error body. */\nexport class ApiError extends Error {\n public readonly url: string;\n public readonly status: number;\n public readonly statusText: string;\n public readonly body: unknown;\n constructor(url: string, status: number, statusText: string, body: unknown) {\n super(`Request failed with status ${status}`);\n this.name = 'ApiError';\n this.url = url;\n this.status = status;\n this.statusText = statusText;\n this.body = body;\n }\n}\n\n/** The error thrown when a request attempt exceeds the configured `timeout` — carries\n * the context a log line needs (which operation, what budget, which attempt). */\nexport class TimeoutError extends Error {\n public readonly operationId: string;\n public readonly timeout: number;\n public readonly attempt: number;\n constructor(operationId: string, timeout: number, attempt: number) {\n super(`Request \"${operationId}\" timed out after ${timeout} ms (attempt ${attempt})`);\n this.name = 'TimeoutError';\n this.operationId = operationId;\n this.timeout = timeout;\n this.attempt = attempt;\n }\n}\n\n/** The error to throw for an aborted request: the caller's abort reason when it is an Error. */\n// `globalThis.Error` (not bare `Error`) so a spec schema named `Error` cannot shadow it\n// when this module is embedded alongside generated types (inline mode).\nexport function abortError(signal: AbortSignal): globalThis.Error {\n const reason = (signal as { reason?: unknown }).reason;\n if (reason instanceof Error) return reason;\n return new DOMException('The operation was aborted.', 'AbortError');\n}\n", 'url.ts': @@ -21,17 +21,17 @@ export const RUNTIME_SOURCES = { 'sse.ts': "import { ApiError } from './errors.js';\nimport { readError } from './parse.js';\nimport { sleep } from './retry.js';\nimport { send, toHeaderRecord } from './send.js';\nimport type { ClientConfig, OperationContext, ServerSentEvent, SseOptions } from './types.js';\n\n/**\n * A frame delimiter: two consecutive line terminators (each CR, LF, or CRLF, per the SSE\n * spec — so mixed endings like `\\n\\r\\n` are valid boundaries, not just matching pairs).\n */\nconst FRAME_DELIMITER = /(?:\\r\\n|\\r|\\n){2}/;\n\n/**\n * A terminally malformed event stream — unparseable JSON `data` or an unbounded frame.\n * A stable bad payload, not a dropped connection, so the stream never reconnects on it.\n */\nexport class SseParseError extends Error {}\n\n/**\n * Consume a `text/event-stream` operation as typed events (capability module — wired\n * into `createClient`). Auto-reconnects on dropped connections, resuming from the last\n * seen event id via `Last-Event-ID` (backoff: the server's `retry:` value, then\n * `reconnectDelay`, then 1s — exponential with jitter, capped at 30s). A clean stream\n * end flushes a trailing frame and finishes; `break`/abort end the iterator cleanly.\n */\nexport async function* sse(\n config: ClientConfig,\n op: OperationContext,\n prepare: () => Promise<{ url: string; init: SseOptions; body?: unknown }>,\n dataKind: 'json' | 'text' = 'text'\n): AsyncGenerator> {\n let lastEventId: string | undefined;\n let serverRetry: number | undefined;\n let failures = 0;\n while (true) {\n // Re-prepare each attempt so a refresh-style TokenProvider yields a fresh credential\n // on reconnect (the auth is baked into `url` query + `init.headers`). `reconnect`,\n // `reconnectDelay`, and `signal` come from the caller's original options unchanged.\n const { url, init, body: requestBody } = await prepare();\n const { reconnect = true, reconnectDelay, ...rest } = init;\n const signal = rest.signal ?? undefined;\n if (signal?.aborted) return;\n const headers: Record = {\n Accept: 'text/event-stream',\n ...toHeaderRecord(rest.headers),\n };\n const sendHeaders =\n lastEventId === undefined ? headers : { ...headers, 'Last-Event-ID': lastEventId };\n try {\n const { response } = await send(\n config,\n op,\n url,\n // `timeout: 0` opts the stream out of a config-level timeout — an event stream\n // is long-lived by design and must not be severed after N milliseconds.\n { ...rest, method: rest.method ?? 'GET', headers: sendHeaders, timeout: 0 },\n requestBody,\n undefined,\n {}\n );\n if (!response.ok) {\n const errorBody = await readError(response);\n throw new ApiError(url, response.status, response.statusText, errorBody);\n }\n failures = 0;\n const body = response.body;\n if (!body) return;\n const reader = body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n try {\n while (true) {\n const { done, value } = await reader.read();\n buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });\n let match: RegExpExecArray | null;\n while ((match = FRAME_DELIMITER.exec(buffer)) !== null) {\n const raw = buffer.slice(0, match.index);\n buffer = buffer.slice(match.index + match[0].length);\n const event = parseSseFrame(raw, dataKind);\n if (event) {\n if (event.id !== undefined) lastEventId = event.id;\n if (event.retry !== undefined) serverRetry = event.retry;\n yield event as ServerSentEvent;\n }\n }\n if (done) {\n // Stream closed cleanly. Flush a final event that arrived without a trailing\n // delimiter, then finish — a clean end is not a dropped connection, so do not reconnect.\n const event = buffer.length > 0 ? parseSseFrame(buffer, dataKind) : undefined;\n if (event) {\n if (event.id !== undefined) lastEventId = event.id;\n if (event.retry !== undefined) serverRetry = event.retry;\n yield event as ServerSentEvent;\n }\n return;\n }\n // Bound memory: a server that never sends a frame delimiter would otherwise\n // grow `buffer` without limit. 1 MiB is far above any real SSE frame.\n if (buffer.length > 1048576) {\n throw new SseParseError('SSE frame exceeded 1048576 characters without a delimiter');\n }\n }\n } finally {\n await reader.cancel().catch(() => undefined);\n }\n } catch (error) {\n if (signal?.aborted) return;\n // A non-OK HTTP response (4xx/5xx) or an unparseable JSON payload is a definitive\n // error, not a transient drop — surface it instead of reconnecting in a loop (a\n // stable bad payload would otherwise reconnect forever).\n if (error instanceof ApiError || error instanceof SseParseError) throw error;\n // A transport failure (connect/DNS/reset) when opening the request, or a mid-stream\n // read error, is a dropped connection: fall through to backoff/reconnect when enabled.\n if (!reconnect) throw error;\n }\n // Only the swallowed-drop path reaches here: reconnect is on and the signal not aborted.\n failures++;\n const base = serverRetry ?? reconnectDelay ?? 1000;\n const delay = Math.min(base * Math.pow(2, failures - 1), 30_000);\n try {\n await sleep(Math.random() * delay, signal);\n } catch {\n return; // sleep rejects only on abort — end the iterator cleanly\n }\n }\n}\n\n/** Parse one raw SSE frame (its lines) into an event; returns undefined for comment-only frames. */\nexport function parseSseFrame(\n raw: string,\n dataKind: 'json' | 'text'\n): ServerSentEvent | undefined {\n let event: string | undefined;\n const dataLines: string[] = [];\n let id: string | undefined;\n let retry: number | undefined;\n let sawField = false;\n for (const line of raw.split(/\\r\\n|\\n|\\r/)) {\n if (line === '' || line.startsWith(':')) continue;\n const colon = line.indexOf(':');\n const field = colon === -1 ? line : line.slice(0, colon);\n let val = colon === -1 ? '' : line.slice(colon + 1);\n if (val.startsWith(' ')) val = val.slice(1);\n sawField = true;\n if (field === 'event') event = val;\n else if (field === 'data') dataLines.push(val);\n else if (field === 'id') id = val;\n else if (field === 'retry') {\n // ASCII digits only, per the EventSource spec — anything else is ignored\n // (`Number('')` is 0 and would zero the reconnect backoff).\n if (/^\\d+$/.test(val)) retry = Number(val);\n }\n }\n if (!sawField) return undefined;\n const dataText = dataLines.join('\\n');\n let data: unknown = dataText;\n if (dataKind === 'json' && dataText !== '') {\n try {\n data = JSON.parse(dataText);\n } catch (error) {\n throw new SseParseError(\n `Failed to parse SSE event data as JSON: ${error instanceof Error ? error.message : String(error)}`\n );\n }\n }\n return { event, data, id, retry };\n}\n", 'create-client.ts': - "import { ApiError } from './errors.js';\nimport { parse, readError } from './parse.js';\nimport { middlewareChain, send, toHeaderRecord, type SendCapabilities } from './send.js';\nimport type {\n ApiErrorLike,\n Client,\n ClientConfig,\n Middleware,\n OperationContext,\n OperationDescriptor,\n OpsShape,\n PaginationSpec,\n ParseAs,\n QueryValue,\n RequestOptions,\n ResponseHeaderSpec,\n SecuritySpec,\n ServerSentEvent,\n SseOptions,\n TokenProvider,\n} from './types.js';\nimport { buildUrl, substitutePath, type QueryStyle } from './url.js';\n\n/**\n * The optional behaviors `createClientCore` can dispatch to but never statically\n * imports. The package's public `createClient` wires the full set; the future\n * inline-mode assembler wires only the capabilities a spec needs.\n */\nexport type Capabilities = SendCapabilities & {\n resolveAuth?: (\n security: readonly (readonly SecuritySpec[])[],\n config: ClientConfig\n ) => Promise<{ headers: Record; query: Record }>;\n sse?: (\n config: ClientConfig,\n op: OperationContext,\n // Re-preparing per (re)connect (not a frozen url/init) lets a refresh-style\n // TokenProvider issue a fresh credential after a dropped stream reconnects.\n prepare: () => Promise<{ url: string; init: SseOptions }>,\n dataKind: 'json' | 'text'\n ) => AsyncGenerator>;\n paginate?: {\n pages: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n items: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n // The `link`-style iterators need the raw `Link` header + page URL, which the\n // parsed-page call above cannot carry (the shape mirrors paginate's `LinkPageCall`).\n pagesByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n itemsByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n };\n};\n\n/**\n * One call's inputs, namespaced by transport layer. `argsStyle: 'flat'` clients accept the\n * merged form instead (every parameter and body property at one level) — `namespaceArgs`\n * converts it to this shape before anything downstream reads it.\n */\nexport type OperationArgs = {\n path?: Record;\n query?: Record;\n body?: unknown;\n headers?: Record;\n cookies?: Record;\n} & Record;\n\n/** The five layer keys, and the only top-level keys a namespaced call may carry. */\nconst LAYERS: readonly string[] = ['path', 'query', 'body', 'headers', 'cookies'];\n\n/** Where a declared parameter's `in` value puts it. */\nconst LAYER_OF: Record = {\n path: 'path',\n query: 'query',\n header: 'headers',\n cookie: 'cookies',\n};\n\n/**\n * Merged (`argsStyle: 'flat'`) args → the namespaced shape. A key that names a declared\n * parameter goes to that parameter's layer; anything else is a property of the request\n * body, which is how a flat call spells an object body. `body` stays reserved for the\n * operations a flat call cannot merge (an array, a scalar, or a binary body).\n */\nfunction namespaceArgs(op: OperationDescriptor, args: OperationArgs): OperationArgs {\n const layers: Record> = {};\n let body: unknown;\n let properties: Record | undefined;\n const layerOfParam = new Map((op.params ?? []).map((param) => [param.name, param.in]));\n for (const [key, value] of Object.entries(args)) {\n const layer = LAYER_OF[layerOfParam.get(key) ?? ''];\n if (layer !== undefined) {\n (layers[layer] ??= {})[key] = value;\n } else if (key === 'body' && op.body !== undefined) {\n body = value;\n } else if (op.body !== undefined) {\n (properties ??= {})[key] = value;\n } else {\n throw new TypeError(\n `Unknown argument \"${key}\" for operation \"${op.id}\": it names no declared parameter, and the operation takes no request body.`\n );\n }\n }\n const namespaced: OperationArgs = {};\n if (layers.path) namespaced.path = layers.path;\n // The flat surface types every query value, so the collected bag is one by construction.\n if (layers.query) namespaced.query = layers.query as Record;\n if (layers.headers) namespaced.headers = layers.headers;\n if (layers.cookies) namespaced.cookies = layers.cookies;\n if (properties !== undefined) namespaced.body = properties;\n else if (body !== undefined) namespaced.body = body;\n return namespaced;\n}\n\n/** The response reader implied by the descriptor (before any per-call `parseAs` override). */\n/**\n * The `Accept` header matching how the response will be read — a blob/text operation\n * must not ask for `application/json` (a content-negotiating server would 406 or\n * answer with a JSON error body instead of the payload). Caller `init.headers` and\n * `config.headers` still override.\n */\nfunction acceptFor(kind: ParseAs | 'void'): string {\n if (kind === 'text') return 'text/*';\n if (kind === 'blob' || kind === 'arrayBuffer' || kind === 'stream' || kind === 'formData') {\n return '*/*';\n }\n return 'application/json'; // json | auto | void\n}\n\nfunction kindFor(op: OperationDescriptor): ParseAs | 'void' {\n if (op.responseKind === 'void' || op.responseKind === 'blob' || op.responseKind === 'text') {\n return op.responseKind;\n }\n return 'auto';\n}\n\n/** The call's inputs in namespaced form, converting first on a flat-style client. */\nfunction inputOf(\n op: OperationDescriptor,\n args: OperationArgs,\n config: ClientConfig\n): OperationArgs {\n return config.argsStyle === 'flat' ? namespaceArgs(op, args) : args;\n}\n\n/** Route the namespaced args to the request pieces. */\nfunction splitArgs(op: OperationDescriptor, args: OperationArgs) {\n // An unknown layer key can only be a bug (usually flat-style args on a namespaced\n // client). TypeScript catches it, but a transpiler that skips type-checking would\n // otherwise ship a request that silently drops the value — fail the call loudly.\n for (const key of Object.keys(args)) {\n if (!LAYERS.includes(key)) {\n throw new TypeError(\n `Unknown argument \"${key}\" for operation \"${op.id}\". Inputs are grouped by layer: ${LAYERS.join(', ')}.`\n );\n }\n }\n return {\n path: args.path ?? {},\n query: args.query,\n body: args.body,\n headers: args.headers,\n cookies: args.cookies,\n };\n}\n\n/**\n * The query-serialization hints for the descriptor's query params. A spec is built only\n * when the param deviates from the OpenAPI defaults (`form` + `explode: true`, encoded),\n * and always fully resolved — so `explode: false` or `allowReserved` alone (no `style`)\n * are honored, and an omitted `explode` keeps the exploded default.\n */\nfunction queryStyles(op: OperationDescriptor): Record | undefined {\n let styles: Record | undefined;\n for (const param of op.params ?? []) {\n if (param.in !== 'query') continue;\n const deviates =\n (param.style !== undefined && param.style !== 'form') ||\n param.explode === false ||\n param.allowReserved === true;\n if (!deviates) continue;\n styles ??= {};\n styles[param.name] = {\n style: param.style ?? 'form',\n explode: param.explode ?? true,\n allowReserved: param.allowReserved,\n };\n }\n return styles;\n}\n\n/** Stringify caller-supplied extra headers, skipping empty entries. */\nfunction stringHeaders(headers: Record | undefined): Record {\n const out: Record = {};\n for (const [key, value] of Object.entries(headers ?? {})) {\n if (value !== undefined && value !== null) out[key] = String(value);\n }\n return out;\n}\n\n/** Build the request pieces an attempt needs: the final URL and the merged per-call init. */\nasync function prepareRequest(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions | SseOptions,\n caps: Capabilities\n): Promise<{ url: string; init: RequestOptions; body: unknown }> {\n const { path, query, body, headers, cookies } = splitArgs(op, args);\n const authed: { headers: Record; query: Record } =\n op.security?.length && caps.resolveAuth\n ? await caps.resolveAuth(op.security, config)\n : { headers: {}, query: {} };\n // Cookie params join the auth-injected cookies in one `Cookie` header (values\n // percent-encoded, like auth cookies). Server-side only — browsers own the header.\n const cookiePairs = Object.entries(cookies ?? {})\n .filter(([, value]) => value !== undefined && value !== null)\n .map(([cookieName, value]) => `${cookieName}=${encodeURIComponent(String(value))}`);\n if (cookiePairs.length > 0) {\n authed.headers.Cookie = [authed.headers.Cookie, ...cookiePairs].filter(Boolean).join('; ');\n }\n const fullQuery: Record = { ...query, ...authed.query };\n const url = buildUrl(\n config.serverUrl ?? '',\n substitutePath(op.path, path),\n Object.keys(fullQuery).length > 0 ? fullQuery : undefined,\n queryStyles(op)\n );\n const mergedInit: RequestOptions = {\n ...init,\n method: op.method.toUpperCase(),\n // Precedence, lowest → highest (later spreads win): injected auth → explicit\n // header params → caller `init.headers` — the caller always overrides both.\n headers: {\n ...authed.headers,\n ...stringHeaders(headers),\n ...toHeaderRecord(init.headers),\n },\n };\n return { url, init: mergedInit, body };\n}\n\n/** Coerce a single declared response header value; omit when absent or unparsable. */\nfunction coerceResponseHeader(\n raw: string | null,\n type: ResponseHeaderSpec['type']\n): string | number | boolean | undefined {\n if (raw === null) return undefined;\n if (type === 'number') {\n if (raw.trim() === '') return undefined;\n const value = Number(raw);\n return Number.isFinite(value) ? value : undefined;\n }\n if (type === 'boolean') {\n const value = raw.trim().toLowerCase();\n if (value === 'true') return true;\n if (value === 'false') return false;\n return undefined;\n }\n return raw;\n}\n\n/** Build the camelCase declared-header bag for a throw-mode envelope. */\nfunction readEnvelopeHeaders(\n response: Response,\n specs: readonly ResponseHeaderSpec[] | undefined\n): Record {\n const headers: Record = {};\n for (const spec of specs ?? []) {\n const value = coerceResponseHeader(response.headers.get(spec.name), spec.type);\n if (value !== undefined) headers[spec.key] = value;\n }\n return headers;\n}\n\n/** One non-SSE call: send, then branch on the configured error mode. */\nasync function execute(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions,\n caps: Capabilities\n): Promise {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // `parseAs` / `envelope` are client options, not fetch RequestInit fields.\n const { parseAs, envelope, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const { response, context } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (config.errorMode === 'result') {\n if (!response.ok) {\n return { data: undefined, error: await readError(response), response };\n }\n return { data: await parse(response, readKind), error: undefined, response };\n }\n if (!response.ok) {\n let error: globalThis.Error = new ApiError(\n context.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n // Thread the error through each middleware's onError in turn (each may replace it).\n for (const mw of middlewareChain(config)) {\n if (mw.onError) error = await mw.onError(error as ApiErrorLike, context);\n }\n throw error;\n }\n const data = await parse(response, readKind);\n if (envelope === true) {\n return {\n data,\n headers: readEnvelopeHeaders(response, op.responseHeaders),\n response,\n };\n }\n return data;\n}\n\n/** The paginate capability, or a descriptive throw when a paginated op is iterated unwired. */\nfunction paginateCapability(caps: Capabilities, op: OperationDescriptor) {\n if (!caps.paginate) {\n throw new Error(`Pagination capability not wired: cannot iterate operation \"${op.id}\"`);\n }\n return caps.paginate;\n}\n\n/**\n * The per-page call the iterators drive: the method itself in throw mode; in result\n * mode a wrapper that unwraps the `{ data, error, response }` envelope — the page\n * pointers are data-rooted — rethrowing a failed page as `ApiError` (iteration is\n * error-mode-agnostic; the throw-mode-only `onError` middleware hook is not invoked).\n */\nfunction pageCall(\n method: (args?: OperationArgs, init?: RequestOptions) => Promise,\n config: ClientConfig\n) {\n const callWithoutEnvelope = (args?: OperationArgs, init?: RequestOptions) => {\n if (!init || init.envelope === undefined) return method(args, init);\n const { envelope: _envelope, ...pageInit } = init;\n return method(args, pageInit);\n };\n if (config.errorMode !== 'result') return callWithoutEnvelope;\n return async (args?: OperationArgs, init?: RequestOptions) => {\n const envelope = (await callWithoutEnvelope(args, init)) as {\n data: unknown;\n error: unknown;\n response: Response;\n };\n // Failure is `!response.ok` — NOT `data === undefined`: a successful bodyless page\n // (204/void) also parses to undefined data, and a failed page's `error` can be\n // undefined too (unreadable body). The pointers then miss on the undefined data\n // and iteration stops cleanly, which is the correct semantics for an empty page.\n if (!envelope.response.ok) {\n const { response } = envelope;\n throw new ApiError(response.url, response.status, response.statusText, envelope.error);\n }\n return envelope.data;\n };\n}\n\n/**\n * The per-page call the `link`-style iterators drive: like `execute`, but returning the\n * parsed page together with the raw `Link` header and the page's own URL (for resolving\n * a relative `rel=\"next\"` target). Error-mode-agnostic like all iteration: a failed\n * page throws `ApiError` even on result-mode clients.\n */\nfunction linkPageCall(config: ClientConfig, op: OperationDescriptor, caps: Capabilities) {\n return async (args: OperationArgs = {}, init: RequestOptions = {}) => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const { parseAs, envelope: _envelope, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n const { response } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (!response.ok) {\n throw new ApiError(\n prepared.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n }\n return {\n page: await parse(response, readKind),\n linkHeader: response.headers.get('link'),\n // Some `Response` implementations leave `url` empty (mocks, constructed responses).\n url: response.url === '' ? prepared.url : response.url,\n };\n };\n}\n\n/**\n * Build a typed instance client over operation descriptors: one real bound method per\n * operation (attached by a construction-time loop — no Proxy), plus the core members\n * (`configure`/`use`/`auth`), which are assigned AFTER the loop so they win any name\n * collision with an operation. All behavior dispatches through the capability seam.\n */\nexport function createClientCore<\n Ops extends OpsShape,\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n>(\n operations: Record,\n initial: ClientConfig> = {},\n caps: Capabilities = {}\n): Client> {\n // The literal-union narrowing is a compile-time DX contract only; internally the\n // runtime works with the base (string-typed) context. One cast at this boundary —\n // `ClientConfig` is not assignable to `ClientConfig` (middleware ctx\n // params are contravariant).\n const given = initial as ClientConfig;\n // Private mutable config; the middleware array is copied so `use()` never mutates the caller's.\n const config: ClientConfig = { ...given, middleware: [...(given.middleware ?? [])] };\n const client = {} as Record;\n\n for (const [name, op] of Object.entries(operations)) {\n if (op.responseKind === 'sse') {\n const method = (given: OperationArgs = {}, init: SseOptions = {}) => {\n const args = inputOf(op, given, config);\n if (!caps.sse) {\n throw new Error(`SSE capability not wired: cannot stream operation \"${op.id}\"`);\n }\n const stream = caps.sse;\n return (async function* () {\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // A thunk the stream re-runs on every (re)connect, so auth (which `prepareRequest`\n // resolves) is refreshed per attempt rather than frozen at the first connect.\n const prepare = async () => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n return { url: prepared.url, init: prepared.init as SseOptions, body: prepared.body };\n };\n yield* stream(config, opCtx, prepare, op.sseDataKind ?? 'text');\n })();\n };\n // Consumers key off the function reference (cache keys, `OPERATIONS[fn.name]`), so\n // each closure carries its operationId instead of an inferred binding name.\n // `operationId` is the explicit, minification-proof form of the same identity\n // (the SPEC operationId — `name` is the emitted key, which a collision may rename).\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n client[name] = method;\n } else {\n // `raw` takes namespaced args; `method` is the public entry that accepts whichever\n // style the client was generated with. The iterators namespace once and then drive\n // `raw`, so a flat call is never converted twice.\n const raw = (args: OperationArgs = {}, init: RequestOptions = {}) =>\n execute(config, op, args, init, caps);\n const method = (args: OperationArgs = {}, init: RequestOptions = {}) =>\n raw(inputOf(op, args, config), init);\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n const spec = op.pagination;\n // Paginated ops keep their one-shot call and gain `.pages`/`.items`, dispatching\n // through the capability seam (like SSE: absent capability throws descriptively).\n // Iteration is error-mode-agnostic: the iterators' pointers are data-rooted, so on\n // a result-mode client (`errorMode` is fixed at construction — `configure()`\n // ignores it) each page's envelope is unwrapped before it reaches the capability.\n // A failed page aborts iteration by throwing ApiError, even on result-mode\n // clients; the `onError` middleware hook (throw-mode-only) is not invoked.\n client[name] =\n spec === undefined\n ? method\n : spec.style === 'link'\n ? Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pagesByLink(\n linkPageCall(config, op, caps),\n inputOf(op, args ?? {}, config),\n init\n ),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).itemsByLink(\n linkPageCall(config, op, caps),\n spec,\n inputOf(op, args ?? {}, config),\n init\n ),\n })\n : Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pages(\n pageCall(raw, config),\n spec,\n inputOf(op, args ?? {}, config),\n init\n ),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).items(\n pageCall(raw, config),\n spec,\n inputOf(op, args ?? {}, config),\n init\n ),\n });\n }\n }\n\n // Core members are assigned AFTER the operation loop — they win over colliding op names.\n client.configure = (next: ClientConfig): void => {\n // `errorMode` and `argsStyle` are fixed at generate time (they shape the static types);\n // flipping either at runtime would silently desync the calls from `Client`, so both\n // are ignored here.\n const { errorMode: _fixedMode, argsStyle: _fixedStyle, auth, ...rest } = next;\n Object.assign(config, rest);\n // `auth` merges into existing credentials (like the `auth.*` setters) rather than\n // replacing wholesale — so `configure({ auth: { bearer } })` keeps a previously set\n // basic/apiKey. `apiKey` merges per scheme.\n if (auth) {\n config.auth = {\n ...config.auth,\n ...auth,\n ...(auth.apiKey ? { apiKey: { ...config.auth?.apiKey, ...auth.apiKey } } : {}),\n };\n }\n };\n client.use = (...middleware: Middleware[]): void => {\n // Reassign (don't push) so a caller-provided `middleware` array isn't mutated.\n config.middleware = [...(config.middleware ?? []), ...middleware];\n };\n client.auth = {\n bearer(token: TokenProvider): void {\n config.auth = { ...config.auth, bearer: token };\n },\n basic(username: string, password: string): void {\n config.auth = { ...config.auth, basic: { username, password } };\n },\n apiKey(scheme: string, value: TokenProvider): void {\n config.auth = { ...config.auth, apiKey: { ...config.auth?.apiKey, [scheme]: value } };\n },\n };\n\n return client as Client>;\n}\n", + "import { ApiError } from './errors.js';\nimport { parse, readError } from './parse.js';\nimport { middlewareChain, send, toHeaderRecord, type SendCapabilities } from './send.js';\nimport type {\n ApiErrorLike,\n Client,\n ClientConfig,\n Middleware,\n OperationContext,\n OperationDescriptor,\n OpsShape,\n PaginationSpec,\n ParseAs,\n QueryValue,\n RequestOptions,\n ResponseHeaderSpec,\n SecuritySpec,\n ServerSentEvent,\n SseOptions,\n TokenProvider,\n} from './types.js';\nimport { buildUrl, substitutePath, type QueryStyle } from './url.js';\n\n/**\n * The optional behaviors `createClientCore` can dispatch to but never statically\n * imports. The package's public `createClient` wires the full set; the future\n * inline-mode assembler wires only the capabilities a spec needs.\n */\nexport type Capabilities = SendCapabilities & {\n resolveAuth?: (\n security: readonly (readonly SecuritySpec[])[],\n config: ClientConfig\n ) => Promise<{ headers: Record; query: Record }>;\n sse?: (\n config: ClientConfig,\n op: OperationContext,\n // Re-preparing per (re)connect (not a frozen url/init) lets a refresh-style\n // TokenProvider issue a fresh credential after a dropped stream reconnects.\n prepare: () => Promise<{ url: string; init: SseOptions }>,\n dataKind: 'json' | 'text'\n ) => AsyncGenerator>;\n paginate?: {\n pages: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n items: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n // The `link`-style iterators need the raw `Link` header + page URL, which the\n // parsed-page call above cannot carry (the shape mirrors paginate's `LinkPageCall`).\n pagesByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n itemsByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n };\n};\n\n/**\n * One call's inputs, namespaced by transport layer. `argsStyle: 'flat'` clients accept the\n * merged form instead (every parameter and body property at one level) — `namespaceArgs`\n * converts it to this shape before anything downstream reads it.\n */\nexport type OperationArgs = {\n path?: Record;\n query?: Record;\n body?: unknown;\n headers?: Record;\n cookies?: Record;\n} & Record;\n\n/** The five layer keys, and the only top-level keys a namespaced call may carry. */\nconst LAYERS: readonly string[] = ['path', 'query', 'body', 'headers', 'cookies'];\n\n/** Where a declared parameter's `in` value puts it. */\nconst LAYER_OF: Record = {\n path: 'path',\n query: 'query',\n header: 'headers',\n cookie: 'cookies',\n};\n\n/**\n * Merged (`argsStyle: 'flat'`) args → the namespaced shape. A key that names a declared\n * parameter goes to that parameter's layer; anything else is a property of the request\n * body, which is how a flat call spells an object body. `body` stays reserved for the\n * operations a flat call cannot merge (an array, a scalar, or a binary body).\n */\nfunction namespaceArgs(op: OperationDescriptor, args: OperationArgs): OperationArgs {\n const layers: Record> = {};\n let body: unknown;\n let properties: Record | undefined;\n const layerOfParam = new Map((op.params ?? []).map((param) => [param.name, param.in]));\n for (const [key, value] of Object.entries(args)) {\n const layer = LAYER_OF[layerOfParam.get(key) ?? ''];\n if (layer !== undefined) {\n (layers[layer] ??= {})[key] = value;\n } else if (key === 'body' && op.body !== undefined) {\n body = value;\n } else if (op.body !== undefined) {\n (properties ??= {})[key] = value;\n } else {\n throw new TypeError(\n `Unknown argument \"${key}\" for operation \"${op.id}\": it names no declared parameter, and the operation takes no request body.`\n );\n }\n }\n const namespaced: OperationArgs = {};\n if (layers.path) namespaced.path = layers.path;\n // The flat surface types every query value, so the collected bag is one by construction.\n if (layers.query) namespaced.query = layers.query as Record;\n if (layers.headers) namespaced.headers = layers.headers;\n if (layers.cookies) namespaced.cookies = layers.cookies;\n if (properties !== undefined) namespaced.body = properties;\n else if (body !== undefined) namespaced.body = body;\n return namespaced;\n}\n\n/** The response reader implied by the descriptor (before any per-call `parseAs` override). */\n/**\n * The `Accept` header matching how the response will be read — a blob/text operation\n * must not ask for `application/json` (a content-negotiating server would 406 or\n * answer with a JSON error body instead of the payload). Caller `init.headers` and\n * `config.headers` still override.\n */\nfunction acceptFor(kind: ParseAs | 'void'): string {\n if (kind === 'text') return 'text/*';\n if (kind === 'blob' || kind === 'arrayBuffer' || kind === 'stream' || kind === 'formData') {\n return '*/*';\n }\n return 'application/json'; // json | auto | void\n}\n\nfunction kindFor(op: OperationDescriptor): ParseAs | 'void' {\n if (op.responseKind === 'void' || op.responseKind === 'blob' || op.responseKind === 'text') {\n return op.responseKind;\n }\n return 'auto';\n}\n\n/**\n * The call's inputs in namespaced form, converting first on a flat-style client. An\n * operation the generator marked `argsStyle: 'grouped'` is already namespaced — its names\n * could not be merged, so its input type never offered the flat shape.\n */\nfunction inputOf(\n op: OperationDescriptor,\n args: OperationArgs,\n config: ClientConfig\n): OperationArgs {\n const merged = config.argsStyle === 'flat' && op.argsStyle !== 'grouped';\n return merged ? namespaceArgs(op, args) : args;\n}\n\n/** Route the namespaced args to the request pieces. */\nfunction splitArgs(op: OperationDescriptor, args: OperationArgs) {\n // An unknown layer key can only be a bug (usually flat-style args on a namespaced\n // client). TypeScript catches it, but a transpiler that skips type-checking would\n // otherwise ship a request that silently drops the value — fail the call loudly.\n for (const key of Object.keys(args)) {\n if (!LAYERS.includes(key)) {\n throw new TypeError(\n `Unknown argument \"${key}\" for operation \"${op.id}\". Inputs are grouped by layer: ${LAYERS.join(', ')}.`\n );\n }\n }\n return {\n path: args.path ?? {},\n query: args.query,\n body: args.body,\n headers: args.headers,\n cookies: args.cookies,\n };\n}\n\n/**\n * The query-serialization hints for the descriptor's query params. A spec is built only\n * when the param deviates from the OpenAPI defaults (`form` + `explode: true`, encoded),\n * and always fully resolved — so `explode: false` or `allowReserved` alone (no `style`)\n * are honored, and an omitted `explode` keeps the exploded default.\n */\nfunction queryStyles(op: OperationDescriptor): Record | undefined {\n let styles: Record | undefined;\n for (const param of op.params ?? []) {\n if (param.in !== 'query') continue;\n const deviates =\n (param.style !== undefined && param.style !== 'form') ||\n param.explode === false ||\n param.allowReserved === true;\n if (!deviates) continue;\n styles ??= {};\n styles[param.name] = {\n style: param.style ?? 'form',\n explode: param.explode ?? true,\n allowReserved: param.allowReserved,\n };\n }\n return styles;\n}\n\n/** Stringify caller-supplied extra headers, skipping empty entries. */\nfunction stringHeaders(headers: Record | undefined): Record {\n const out: Record = {};\n for (const [key, value] of Object.entries(headers ?? {})) {\n if (value !== undefined && value !== null) out[key] = String(value);\n }\n return out;\n}\n\n/** Build the request pieces an attempt needs: the final URL and the merged per-call init. */\nasync function prepareRequest(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions | SseOptions,\n caps: Capabilities\n): Promise<{ url: string; init: RequestOptions; body: unknown }> {\n const { path, query, body, headers, cookies } = splitArgs(op, args);\n const authed: { headers: Record; query: Record } =\n op.security?.length && caps.resolveAuth\n ? await caps.resolveAuth(op.security, config)\n : { headers: {}, query: {} };\n // Cookie params join the auth-injected cookies in one `Cookie` header (values\n // percent-encoded, like auth cookies). Server-side only — browsers own the header.\n const cookiePairs = Object.entries(cookies ?? {})\n .filter(([, value]) => value !== undefined && value !== null)\n .map(([cookieName, value]) => `${cookieName}=${encodeURIComponent(String(value))}`);\n if (cookiePairs.length > 0) {\n authed.headers.Cookie = [authed.headers.Cookie, ...cookiePairs].filter(Boolean).join('; ');\n }\n const fullQuery: Record = { ...query, ...authed.query };\n const url = buildUrl(\n config.serverUrl ?? '',\n substitutePath(op.path, path),\n Object.keys(fullQuery).length > 0 ? fullQuery : undefined,\n queryStyles(op)\n );\n const mergedInit: RequestOptions = {\n ...init,\n method: op.method.toUpperCase(),\n // Precedence, lowest → highest (later spreads win): injected auth → explicit\n // header params → caller `init.headers` — the caller always overrides both.\n headers: {\n ...authed.headers,\n ...stringHeaders(headers),\n ...toHeaderRecord(init.headers),\n },\n };\n return { url, init: mergedInit, body };\n}\n\n/** Coerce a single declared response header value; omit when absent or unparsable. */\nfunction coerceResponseHeader(\n raw: string | null,\n type: ResponseHeaderSpec['type']\n): string | number | boolean | undefined {\n if (raw === null) return undefined;\n if (type === 'number') {\n if (raw.trim() === '') return undefined;\n const value = Number(raw);\n return Number.isFinite(value) ? value : undefined;\n }\n if (type === 'boolean') {\n const value = raw.trim().toLowerCase();\n if (value === 'true') return true;\n if (value === 'false') return false;\n return undefined;\n }\n return raw;\n}\n\n/** Build the camelCase declared-header bag for a throw-mode envelope. */\nfunction readEnvelopeHeaders(\n response: Response,\n specs: readonly ResponseHeaderSpec[] | undefined\n): Record {\n const headers: Record = {};\n for (const spec of specs ?? []) {\n const value = coerceResponseHeader(response.headers.get(spec.name), spec.type);\n if (value !== undefined) headers[spec.key] = value;\n }\n return headers;\n}\n\n/** One non-SSE call: send, then branch on the configured error mode. */\nasync function execute(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions,\n caps: Capabilities\n): Promise {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // `parseAs` / `envelope` are client options, not fetch RequestInit fields.\n const { parseAs, envelope, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const { response, context } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (config.errorMode === 'result') {\n if (!response.ok) {\n return { data: undefined, error: await readError(response), response };\n }\n return { data: await parse(response, readKind), error: undefined, response };\n }\n if (!response.ok) {\n let error: globalThis.Error = new ApiError(\n context.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n // Thread the error through each middleware's onError in turn (each may replace it).\n for (const mw of middlewareChain(config)) {\n if (mw.onError) error = await mw.onError(error as ApiErrorLike, context);\n }\n throw error;\n }\n const data = await parse(response, readKind);\n if (envelope === true) {\n return {\n data,\n headers: readEnvelopeHeaders(response, op.responseHeaders),\n response,\n };\n }\n return data;\n}\n\n/** The paginate capability, or a descriptive throw when a paginated op is iterated unwired. */\nfunction paginateCapability(caps: Capabilities, op: OperationDescriptor) {\n if (!caps.paginate) {\n throw new Error(`Pagination capability not wired: cannot iterate operation \"${op.id}\"`);\n }\n return caps.paginate;\n}\n\n/**\n * The per-page call the iterators drive: the method itself in throw mode; in result\n * mode a wrapper that unwraps the `{ data, error, response }` envelope — the page\n * pointers are data-rooted — rethrowing a failed page as `ApiError` (iteration is\n * error-mode-agnostic; the throw-mode-only `onError` middleware hook is not invoked).\n */\nfunction pageCall(\n method: (args?: OperationArgs, init?: RequestOptions) => Promise,\n config: ClientConfig\n) {\n const callWithoutEnvelope = (args?: OperationArgs, init?: RequestOptions) => {\n if (!init || init.envelope === undefined) return method(args, init);\n const { envelope: _envelope, ...pageInit } = init;\n return method(args, pageInit);\n };\n if (config.errorMode !== 'result') return callWithoutEnvelope;\n return async (args?: OperationArgs, init?: RequestOptions) => {\n const envelope = (await callWithoutEnvelope(args, init)) as {\n data: unknown;\n error: unknown;\n response: Response;\n };\n // Failure is `!response.ok` — NOT `data === undefined`: a successful bodyless page\n // (204/void) also parses to undefined data, and a failed page's `error` can be\n // undefined too (unreadable body). The pointers then miss on the undefined data\n // and iteration stops cleanly, which is the correct semantics for an empty page.\n if (!envelope.response.ok) {\n const { response } = envelope;\n throw new ApiError(response.url, response.status, response.statusText, envelope.error);\n }\n return envelope.data;\n };\n}\n\n/**\n * The per-page call the `link`-style iterators drive: like `execute`, but returning the\n * parsed page together with the raw `Link` header and the page's own URL (for resolving\n * a relative `rel=\"next\"` target). Error-mode-agnostic like all iteration: a failed\n * page throws `ApiError` even on result-mode clients.\n */\nfunction linkPageCall(config: ClientConfig, op: OperationDescriptor, caps: Capabilities) {\n return async (args: OperationArgs = {}, init: RequestOptions = {}) => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const { parseAs, envelope: _envelope, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n const { response } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (!response.ok) {\n throw new ApiError(\n prepared.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n }\n return {\n page: await parse(response, readKind),\n linkHeader: response.headers.get('link'),\n // Some `Response` implementations leave `url` empty (mocks, constructed responses).\n url: response.url === '' ? prepared.url : response.url,\n };\n };\n}\n\n/**\n * Build a typed instance client over operation descriptors: one real bound method per\n * operation (attached by a construction-time loop — no Proxy), plus the core members\n * (`configure`/`use`/`auth`), which are assigned AFTER the loop so they win any name\n * collision with an operation. All behavior dispatches through the capability seam.\n */\nexport function createClientCore<\n Ops extends OpsShape,\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n>(\n operations: Record,\n initial: ClientConfig> = {},\n caps: Capabilities = {}\n): Client> {\n // The literal-union narrowing is a compile-time DX contract only; internally the\n // runtime works with the base (string-typed) context. One cast at this boundary —\n // `ClientConfig` is not assignable to `ClientConfig` (middleware ctx\n // params are contravariant).\n const given = initial as ClientConfig;\n // Private mutable config; the middleware array is copied so `use()` never mutates the caller's.\n const config: ClientConfig = { ...given, middleware: [...(given.middleware ?? [])] };\n const client = {} as Record;\n\n for (const [name, op] of Object.entries(operations)) {\n if (op.responseKind === 'sse') {\n const method = (given: OperationArgs = {}, init: SseOptions = {}) => {\n const args = inputOf(op, given, config);\n if (!caps.sse) {\n throw new Error(`SSE capability not wired: cannot stream operation \"${op.id}\"`);\n }\n const stream = caps.sse;\n return (async function* () {\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // A thunk the stream re-runs on every (re)connect, so auth (which `prepareRequest`\n // resolves) is refreshed per attempt rather than frozen at the first connect.\n const prepare = async () => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n return { url: prepared.url, init: prepared.init as SseOptions, body: prepared.body };\n };\n yield* stream(config, opCtx, prepare, op.sseDataKind ?? 'text');\n })();\n };\n // Consumers key off the function reference (cache keys, `OPERATIONS[fn.name]`), so\n // each closure carries its operationId instead of an inferred binding name.\n // `operationId` is the explicit, minification-proof form of the same identity\n // (the SPEC operationId — `name` is the emitted key, which a collision may rename).\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n client[name] = method;\n } else {\n // `raw` takes namespaced args; `method` is the public entry that accepts whichever\n // style the client was generated with. The iterators namespace once and then drive\n // `raw`, so a flat call is never converted twice.\n const raw = (args: OperationArgs = {}, init: RequestOptions = {}) =>\n execute(config, op, args, init, caps);\n const method = (args: OperationArgs = {}, init: RequestOptions = {}) =>\n raw(inputOf(op, args, config), init);\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n const spec = op.pagination;\n // Paginated ops keep their one-shot call and gain `.pages`/`.items`, dispatching\n // through the capability seam (like SSE: absent capability throws descriptively).\n // Iteration is error-mode-agnostic: the iterators' pointers are data-rooted, so on\n // a result-mode client (`errorMode` is fixed at construction — `configure()`\n // ignores it) each page's envelope is unwrapped before it reaches the capability.\n // A failed page aborts iteration by throwing ApiError, even on result-mode\n // clients; the `onError` middleware hook (throw-mode-only) is not invoked.\n client[name] =\n spec === undefined\n ? method\n : spec.style === 'link'\n ? Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pagesByLink(\n linkPageCall(config, op, caps),\n inputOf(op, args ?? {}, config),\n init\n ),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).itemsByLink(\n linkPageCall(config, op, caps),\n spec,\n inputOf(op, args ?? {}, config),\n init\n ),\n })\n : Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pages(\n pageCall(raw, config),\n spec,\n inputOf(op, args ?? {}, config),\n init\n ),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).items(\n pageCall(raw, config),\n spec,\n inputOf(op, args ?? {}, config),\n init\n ),\n });\n }\n }\n\n // Core members are assigned AFTER the operation loop — they win over colliding op names.\n client.configure = (next: ClientConfig): void => {\n // `errorMode` and `argsStyle` are fixed at generate time (they shape the static types);\n // flipping either at runtime would silently desync the calls from `Client`, so both\n // are ignored here.\n const { errorMode: _fixedMode, argsStyle: _fixedStyle, auth, ...rest } = next;\n Object.assign(config, rest);\n // `auth` merges into existing credentials (like the `auth.*` setters) rather than\n // replacing wholesale — so `configure({ auth: { bearer } })` keeps a previously set\n // basic/apiKey. `apiKey` merges per scheme.\n if (auth) {\n config.auth = {\n ...config.auth,\n ...auth,\n ...(auth.apiKey ? { apiKey: { ...config.auth?.apiKey, ...auth.apiKey } } : {}),\n };\n }\n };\n client.use = (...middleware: Middleware[]): void => {\n // Reassign (don't push) so a caller-provided `middleware` array isn't mutated.\n config.middleware = [...(config.middleware ?? []), ...middleware];\n };\n client.auth = {\n bearer(token: TokenProvider): void {\n config.auth = { ...config.auth, bearer: token };\n },\n basic(username: string, password: string): void {\n config.auth = { ...config.auth, basic: { username, password } };\n },\n apiKey(scheme: string, value: TokenProvider): void {\n config.auth = { ...config.auth, apiKey: { ...config.auth?.apiKey, [scheme]: value } };\n },\n };\n\n return client as Client>;\n}\n", 'paginate.ts': "import type { OperationArgs } from './create-client.js';\nimport type { PaginationSpec, QueryValue, RequestOptions } from './types.js';\n\n/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `query` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nexport function resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `query[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nexport async function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.query?.[spec.param];\n while (true) {\n const query = { ...args.query };\n if (cursor !== undefined) query[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, query }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `query[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.query?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call({ ...args, query: { ...args.query, [spec.param]: position } }, init);\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nexport async function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\nexport type LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nexport function linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nexport async function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let query = args.query;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, query }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n query = { ...args.query, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nexport async function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n", 'cli.ts': - "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /**\n * Present when the operation takes a JSON request body. `merged` marks a body whose own\n * properties a flat-style call spells at the top level (the generator decides this from\n * the schema, so the CLI and the client can never disagree).\n */\n body?: { required: boolean; merged?: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n /** The name the CLI is invoked as, for help output only. The generated entry reads it\n * from `process.argv[1]`, so help never names a command that is not installed. */\n name: string;\n /** Credential variable prefix, constant-cased: `CAFE` gives `CAFE_TOKEN`. Fixed at\n * generation from the output file name, so renaming the binary keeps the variables\n * a published CLI already documents. A composed entry sets one per api alias. */\n envPrefix: string;\n /** The generated instance client. */\n client: Record;\n /** How that client takes its inputs. Defaults to `'grouped'`, the generated default. */\n argsStyle?: 'grouped' | 'flat';\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\nexport type CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\nexport type CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\nexport type CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\nexport type CommandSource = {\n namespace?: string;\n commands: Array;\n /** Absent = inherit the first wired source's — a root `login` shares the binary's identity. */\n wiring?: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nexport function groupSlug(group: string): string {\n return group\n .toLowerCase()\n .split(/[^a-z0-9]+/)\n .filter(Boolean)\n .join('-');\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/**\n * The parsed argv as one call input, in the style the wired client takes: grouped by layer\n * (the default) or merged into one object.\n */\nfunction callInputs(\n command: CliCommand,\n positionals: Record,\n params: Record,\n body: unknown,\n argsStyle: CliWiring['argsStyle']\n): Record | undefined {\n const inputs: Record = {};\n if (argsStyle === 'flat') {\n Object.assign(inputs, positionals, params);\n if (body !== undefined) {\n if (command.body?.merged === true) Object.assign(inputs, body as Record);\n else inputs.body = body;\n }\n } else {\n if (Object.keys(positionals).length > 0) inputs.path = positionals;\n if (Object.keys(params).length > 0) inputs.query = params;\n if (body !== undefined) inputs.body = body;\n }\n return Object.keys(inputs).length > 0 ? inputs : undefined;\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n // An untagged operation is only ever addressed by its bare name, so when that name is also\n // a group slug the name wins — reading it as the group would leave the command unreachable.\n // A tagged operation in the same position keeps yielding to group help: it is still\n // reachable as ` `.\n const untagged = commands.some((c) => c.group === undefined && c.name === argv[0]);\n let command: CliCommand | undefined;\n let rest: string[];\n if (!untagged && slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** `cafe-api` → `CAFE_API`: the casing of every credential variable this CLI reads. */\nexport function constantCase(value: string): string {\n return value\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix;\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${constantCase(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n name: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n name,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${name} ${topic} …`, '', 'Commands:']\n : [`Usage: ${name} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${constantCase(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${name} ${grouped ? ' ' : ''} --help for command details; ${name} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nexport async function runCli(sources: CommandSource[], argv: string[]): Promise;\nexport async function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // A source without wiring inherits the first wired one, so the documented root-source\n // shape `{ commands: [login] }` works: the login shares the composed binary's identity.\n const inherited = sources.find((source) => source.wiring !== undefined)?.wiring;\n const wiringOf = (source: CommandSource): CliWiring => source.wiring ?? (inherited as CliWiring);\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = wiringOf(sources[0]);\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.name)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, wiringOf(source), argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, wiringOf(root as CommandSource), argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], name: string): string[] {\n const lines = [`Usage: ${name} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${name} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(\n commands,\n wiring.name,\n wiring.schemes ?? [],\n wiring.envPrefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n // Redaction matches these against header VALUES — so basic auth must contribute the\n // form the wire actually carries (`Basic ${base64(user:pass)}`), not just the raw\n // password, which never appears in the encoded header.\n const basic = auth.basic as { username: string; password: string } | undefined;\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(basic ? [basic.password, btoa(`${basic.username}:${basic.password}`)] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const argument = callInputs(command, positionals, params, body, wiring.argsStyle);\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n // An SSE method returns a lazy stream — drain the stubbed response so its fetch\n // actually runs and captures the request.\n if (command.sse) for await (const _event of result as AsyncIterable);\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", + "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\nexport type CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\nexport type CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /**\n * Present when the operation takes a JSON request body. `merged` marks a body whose own\n * properties a flat-style call spells at the top level (the generator decides this from\n * the schema, so the CLI and the client can never disagree).\n */\n body?: { required: boolean; merged?: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n /** `'grouped'` marks a command whose client method takes namespaced inputs even on a\n * flat-style client, because its merged names would collide. */\n argsStyle?: 'grouped';\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\nexport type CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\nexport type CliWiring = {\n /** The name the CLI is invoked as, for help output only. The generated entry reads it\n * from `process.argv[1]`, so help never names a command that is not installed. */\n name: string;\n /** Credential variable prefix, constant-cased: `CAFE` gives `CAFE_TOKEN`. Fixed at\n * generation from the output file name, so renaming the binary keeps the variables\n * a published CLI already documents. A composed entry sets one per api alias. */\n envPrefix: string;\n /** The generated instance client. */\n client: Record;\n /** How that client takes its inputs. Defaults to `'grouped'`, the generated default. */\n argsStyle?: 'grouped' | 'flat';\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\nexport type CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\nexport type CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\nexport type CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\nexport type CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\nexport type CommandSource = {\n namespace?: string;\n commands: Array;\n /** Absent = inherit the first wired source's — a root `login` shares the binary's identity. */\n wiring?: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The name to print in help: the command the CLI was invoked as. A global install resolves\n * `argv[1]` to the bin itself, so its basename is exactly what the user typed. A Windows\n * `.cmd` shim, a `node dist/cafe.cli.js`, and a `tsx client.cli.ts` run all pass the script\n * path instead — printing that would name a command nobody can type, so a script extension\n * and the `.cli` marker come off: `cafe.cli.js` prints `cafe`.\n */\nexport function invokedName(scriptPath: string | undefined, fallback: string): string {\n if (scriptPath === undefined) return fallback;\n const base = scriptPath.replace(/^.*[\\\\/]/, '');\n const withoutExtension = base.replace(/\\.(mjs|cjs|js|mts|cts|ts|cmd|bat|ps1|exe)$/i, '');\n const name = withoutExtension.replace(/\\.cli$/i, '');\n return name === '' ? fallback : name;\n}\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nexport function groupSlug(group: string): string {\n return group\n .toLowerCase()\n .split(/[^a-z0-9]+/)\n .filter(Boolean)\n .join('-');\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/**\n * The parsed argv as one call input, in the style the wired client takes: grouped by layer\n * (the default) or merged into one object.\n */\nfunction callInputs(\n command: CliCommand,\n positionals: Record,\n params: Record,\n body: unknown,\n argsStyle: CliWiring['argsStyle']\n): Record | undefined {\n const inputs: Record = {};\n // A command the generator marked `grouped` keeps the namespaced shape even here.\n if (argsStyle === 'flat' && command.argsStyle !== 'grouped') {\n Object.assign(inputs, positionals, params);\n if (body !== undefined) {\n if (command.body?.merged === true) Object.assign(inputs, body as Record);\n else inputs.body = body;\n }\n } else {\n if (Object.keys(positionals).length > 0) inputs.path = positionals;\n if (Object.keys(params).length > 0) inputs.query = params;\n if (body !== undefined) inputs.body = body;\n }\n return Object.keys(inputs).length > 0 ? inputs : undefined;\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nexport function parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n // An untagged operation is only ever addressed by its bare name, so when that name is also\n // a group slug the name wins — reading it as the group would leave the command unreachable.\n // A tagged operation in the same position keeps yielding to group help: it is still\n // reachable as ` `.\n const untagged = commands.some((c) => c.group === undefined && c.name === argv[0]);\n let command: CliCommand | undefined;\n let rest: string[];\n if (!untagged && slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** `cafe-api` → `CAFE_API`: the casing of every credential variable this CLI reads. */\nexport function constantCase(value: string): string {\n return value\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix;\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${constantCase(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n name: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n name,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${name} ${topic} …`, '', 'Commands:']\n : [`Usage: ${name} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${constantCase(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${name} ${grouped ? ' ' : ''} --help for command details; ${name} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nexport async function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nexport async function runCli(sources: CommandSource[], argv: string[]): Promise;\nexport async function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // A source without wiring inherits the first wired one, so the documented root-source\n // shape `{ commands: [login] }` works: the login shares the composed binary's identity.\n const inherited = sources.find((source) => source.wiring !== undefined)?.wiring;\n const wiringOf = (source: CommandSource): CliWiring => source.wiring ?? (inherited as CliWiring);\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = wiringOf(sources[0]);\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.name)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, wiringOf(source), argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, wiringOf(root as CommandSource), argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], name: string): string[] {\n const lines = [`Usage: ${name} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${name} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(\n commands,\n wiring.name,\n wiring.schemes ?? [],\n wiring.envPrefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n // Redaction matches these against header VALUES — so basic auth must contribute the\n // form the wire actually carries (`Basic ${base64(user:pass)}`), not just the raw\n // password, which never appears in the encoded header.\n const basic = auth.basic as { username: string; password: string } | undefined;\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(basic ? [basic.password, btoa(`${basic.username}:${basic.password}`)] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const argument = callInputs(command, positionals, params, body, wiring.argsStyle);\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n // An SSE method returns a lazy stream — drain the stubbed response so its fetch\n // actually runs and captures the request.\n if (command.sse) for await (const _event of result as AsyncIterable);\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}\n", } as const; /** Inline-embed variants: imports dropped, `export` stripped outside the kept surface. */ export const RUNTIME_SOURCES_STRIPPED = { 'types.ts': - "/**\n * The public type surface of the client runtime — `@redocly/client-generator`'s\n * app-facing runtime module. Pure types, no runtime code (excluded from coverage).\n * The generator emits `OPERATIONS` literals typed\n * `satisfies Record` against this module, so an\n * incompatible runtime/generated pair fails the consumer's build (the semver skew guard).\n */\n\n/** How one operation parameter is sent: its location plus OpenAPI query-serialization hints. */\nexport type ParamSpec = {\n name: string;\n in: 'path' | 'query' | 'header' | 'cookie';\n style?: 'form' | 'spaceDelimited' | 'pipeDelimited' | 'deepObject';\n explode?: boolean;\n allowReserved?: boolean;\n};\n\n/** One security scheme, denormalized onto the operation (`scheme` names the spec's scheme). */\nexport type SecuritySpec =\n | { scheme: string; kind: 'bearer' | 'basic' }\n | { scheme: string; kind: 'apiKey'; name: string; in: 'header' | 'query' | 'cookie' };\n\n/**\n * How to auto-iterate a paginated operation (drives its `.pages()`/`.items()` members).\n * `nextCursor` and `items` are RFC 6901 JSON pointers into the page (response) value.\n */\nexport type PaginationSpec =\n | {\n style: 'cursor';\n /** The query param the iterator advances with the response's cursor. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the next cursor in the page. */\n nextCursor: string;\n /** Optional pointer to a boolean \"more pages\" flag — `false` stops iteration. */\n hasMore?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n style: 'offset' | 'page';\n /** The numeric query param the iterator advances. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n /** RFC 8288: follow the response's `Link` header `rel=\"next\"`; stop when absent. */\n style: 'link';\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n };\n\n/** The frozen data contract between generated code and the runtime: one operation's wire shape. */\nexport type OperationDescriptor = {\n id: string;\n method: string;\n path: string;\n tags?: readonly string[];\n params?: readonly ParamSpec[];\n /** `multipart: true` marks a typed object body serialized to FormData by the runtime. */\n body?: { contentType: string; multipart?: boolean };\n /** Defaults to `'json'` (content-type negotiation on parse). */\n responseKind?: 'json' | 'text' | 'blob' | 'void' | 'sse';\n sseDataKind?: 'json' | 'text';\n /** OR-alternatives, each an AND-set: the runtime applies the first fully-configured one. */\n security?: readonly (readonly SecuritySpec[])[];\n pagination?: PaginationSpec;\n /**\n * Declared success-response headers for throw-mode `{ envelope: true }`.\n * `name` is the lowercased wire name; `key` is the camelCase envelope property.\n */\n responseHeaders?: readonly ResponseHeaderSpec[];\n};\n\n/** One declared response header the runtime coerces into the envelope `headers` object. */\nexport type ResponseHeaderSpec = {\n name: string;\n key: string;\n type: 'string' | 'number' | 'boolean';\n};\n\n/** A query value: scalars, arrays of scalars, or objects (serialized as deepObject brackets). */\nexport type QueryValue =\n | string\n | number\n | boolean\n | null\n | undefined\n | Array\n | Record;\n\n/** A credential: a literal, or a (possibly async) function resolved per request (refresh flows). */\nexport type TokenProvider = string | (() => string | Promise);\n\n/** Per-instance credentials, keyed by the scheme kinds the runtime can inject. */\nexport type AuthCredentials = {\n bearer?: TokenProvider;\n basic?: { username: string; password: string };\n apiKey?: Record;\n};\n\n/** Backoff shape: 'fixed' = constant delay; 'exponential' = doubling per attempt. */\nexport type RetryStrategy = 'fixed' | 'exponential';\n\n/**\n * The operation's identity, exposed to middleware for targeting (`ctx.operation`).\n * Generated clients instantiate the type parameters with the spec's literal unions\n * (`OperationId`/`OperationPath`/`OperationTag`) so a misspelled operation id in a\n * middleware comparison fails to compile; the string defaults keep every\n * spec-independent consumer (`runtime-contract.ts`, the runtime internals) working\n * with the base shape. `tags` stays mutable (`Tag[]`) so setup-contract types\n * (byte-locked to generated output) remain assignable through middleware callbacks.\n */\nexport type OperationContext<\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n> = { id: Id; path: Path; tags: Tag[] };\n\n/** The mutable request context threaded through the middleware chain. */\nexport type RequestContext = {\n url: string;\n method: string;\n headers: Record;\n body?: unknown;\n operation: Op;\n};\n\n/** The failed attempt handed to a custom `retryOn`: exactly one of `response`/`error` is set. */\nexport type RetryContext = {\n attempt: number;\n request: RequestContext;\n response?: Response;\n error?: unknown;\n};\n\n/** Opt-in retry policy; a per-call override merges field-by-field over the config policy. */\nexport type RetryConfig = {\n retries?: number;\n retryDelay?: number;\n retryStrategy?: RetryStrategy;\n jitter?: boolean;\n retryOn?: (ctx: RetryContext) => boolean | Promise;\n};\n\n/**\n * Structural stand-in for the runtime's ApiError so this module stays import-free\n * (pure types); the real `ApiError` class is assignable to it.\n */\nexport type ApiErrorLike = globalThis.Error & {\n url: string;\n status: number;\n statusText: string;\n body: unknown;\n};\n\n/** One interceptor: any subset of the three hooks. */\nexport type Middleware = {\n onRequest?: (ctx: RequestContext) => void | Promise;\n onResponse?: (\n response: Response,\n ctx: RequestContext\n ) => Response | void | Promise;\n /** Throw mode only: may map/replace the error. */\n // `globalThis.Error` so a spec schema named `Error` cannot shadow it in inline mode.\n onError?: (\n error: ApiErrorLike,\n ctx: RequestContext\n ) => globalThis.Error | Promise;\n};\n\n/** Client configuration: transport, defaults, retry policy, middleware, and credentials. */\nexport type ClientConfig = {\n serverUrl?: string;\n fetch?: typeof fetch;\n headers?:\n | Record\n | (() => Record | Promise>);\n retry?: RetryConfig;\n /** Milliseconds before a request attempt aborts (covers the body read too; each retry\n * attempt gets a fresh budget). Per-call `timeout` overrides it, `0` disables it.\n * SSE streams are long-lived by design and never inherit this value. */\n timeout?: number;\n /** Send an `Idempotency-Key` header on POST/PATCH (one stable key per logical call,\n * reused across retry attempts) — which also makes those retries safe under the\n * default retry policy. `true` generates a UUID per call; a function supplies the key. */\n idempotencyKey?: boolean | (() => string);\n /** Identifies this client to the API via an `X-Redocly-Client` header (the generator\n * bakes a default). Sent only OUTSIDE browsers — a custom header would force a CORS\n * preflight. Override with your own value, or `false` to disable. */\n clientHeader?: string | false;\n middleware?: Middleware[];\n auth?: AuthCredentials;\n /** Fixed at generate time by the generator (`'throw'` when omitted); `configure()` ignores it. */\n errorMode?: 'throw' | 'result';\n /**\n * How each call spells its inputs: `'grouped'` (the default) namespaces them by layer —\n * `{ path, query, headers, cookies, body }` — and `'flat'` takes one merged object.\n * Fixed at generate time, like `errorMode`, because it shapes the static types.\n */\n argsStyle?: 'grouped' | 'flat';\n onRequest?: Middleware['onRequest'];\n onResponse?: Middleware['onResponse'];\n onError?: Middleware['onError'];\n};\n\n/** Response readers for the per-call `parseAs` override. */\nexport type ParseAs = 'auto' | 'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData' | 'stream';\n\n/** Per-call options: standard `RequestInit` plus a retry override, a timeout override\n * (`0` disables the config default), and a forced reader. */\nexport type RequestOptions = RequestInit & {\n retry?: RetryConfig;\n timeout?: number;\n /** Per-call idempotency key: a literal key, `true` to generate one, `false` to skip. */\n idempotencyKey?: string | boolean | (() => string);\n parseAs?: ParseAs;\n /**\n * Throw mode only: return `{ data, headers, response }` instead of the parsed body;\n * ignored in result mode. The explicit `| undefined` keeps the wrappers' emitted\n * `envelope: undefined` strip legal under `exactOptionalPropertyTypes`.\n */\n envelope?: boolean | undefined;\n};\n\n/** Throw-mode success envelope when `RequestOptions.envelope` is `true`. */\nexport type Envelope> = {\n data: TData;\n headers: THeaders;\n response: Response;\n};\n\n/** Per-call options for an SSE stream; reconnect defaults to true. */\nexport type SseOptions = RequestInit & { reconnect?: boolean; reconnectDelay?: number };\n\n/** A single decoded Server-Sent Event with its payload typed from the spec. */\nexport type ServerSentEvent = { event?: string; data: T; id?: string; retry?: number };\n\n/** Result-mode return shape: exactly one of `data`/`error` is set. */\nexport type Result =\n | { data: TData; error: undefined; response: Response }\n | { data: undefined; error: TError; response: Response };\n\n/**\n * The generated `Ops` type's shape: per-operation args/result, plus `kind: 'sse'` for\n * streams and, for paginated operations, `item` (the page's element type) and — on\n * result-mode clients only — `page` (the RAW page type `.pages()` yields, since\n * iteration unwraps the `Result` envelope the one-shot `result` carries).\n */\nexport type OpsShape = Record<\n string,\n {\n args: object;\n result: unknown;\n kind?: 'sse';\n item?: unknown;\n page?: unknown;\n /** Declared success-response headers for `{ envelope: true }` (camelCase keys). */\n headers?: object;\n /** Result-mode entries ignore the throw-only `envelope` option. */\n mode?: 'result';\n }\n>;\n\n/** The always-present client members (assigned after the operation loop — they win collisions). */\nexport type ClientCore = {\n /** Merge into the config; note `middleware` REPLACES the chain (use `use()` to compose). */\n configure(config: ClientConfig): void;\n /** Append interceptors (composes with baked/publisher middleware). */\n use(...middleware: Middleware[]): void;\n auth: {\n bearer(token: TokenProvider): void;\n basic(username: string, password: string): void;\n apiKey(scheme: string, value: TokenProvider): void;\n };\n};\n\n/**\n * The standard TypeScript optionality probe: `{}` has no required members, so\n * `{} extends A` is true exactly when every member of `A` is optional.\n */\n// oxlint-disable-next-line typescript/no-empty-object-type\ntype NoRequiredKeys = {} extends A ? true : false;\n\n/**\n * The page type `.pages()` yields: the RAW page declared by `page` (the generator\n * writes it only on result-mode paginated entries, whose `result` is the envelope),\n * or the method's own `result` (throw mode — already the raw page).\n */\ntype PageOf = Entry extends { page: unknown }\n ? Entry['page']\n : Entry['result'];\n\n/**\n * The auto-pagination members intersected onto a paginated method — present exactly when\n * the Ops entry declares `item` (the generator writes it only for paginated operations).\n * Args optionality mirrors the method's own; `unknown` otherwise (identity under `&`).\n * Iteration is error-mode-agnostic: `.pages()`/`.items()` yield raw pages/items, and a\n * failed page aborts iteration by throwing `ApiError`, even on result-mode clients; the\n * `onError` middleware hook (throw-mode-only) is not invoked.\n */\ntype Paginated = 'item' extends keyof Entry\n ? NoRequiredKeys extends true\n ? {\n pages(args?: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args?: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : {\n pages(args: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : unknown;\n\n/**\n * The stable identity every client method carries: the SPEC operationId (also set as\n * `fn.name`, but `operationId` is the explicit, minification-proof form) — a robust\n * cache key for consumer wrappers (react-query keys and the like).\n */\nexport type OperationMethodIdentity = { readonly operationId: string };\n\n/** Declared response-header bag for an Ops entry; empty object when none are declared. */\ntype HeadersOf = 'headers' extends keyof Entry\n ? NonNullable\n : Record;\n\n/**\n * Return type of a throw-mode call: the body by default, `Envelope<…>` for a literal\n * `envelope: true`, their union when the flag is a widened `boolean`. Exact\n * `RequestOptions` stays the body — pre-envelope package-mode flat sugar typed every\n * `init` parameter as `RequestOptions`, and widening that would break upgrades without\n * a regenerate. The `keyof` presence gate keeps `{ headers }` / `{ signal }` as the body\n * (`TInit['envelope']` through `TInit & RequestOptions` would otherwise be\n * `boolean | undefined`).\n */\nexport type EnvelopeResult<\n TData,\n THeaders,\n TInit extends RequestOptions | undefined,\n> = TInit extends undefined\n ? TData\n : RequestOptions extends TInit\n ? TInit extends RequestOptions\n ? TData\n : EnvelopeResultForKnownInit\n : EnvelopeResultForKnownInit;\n\ntype EnvelopeResultForKnownInit = 'envelope' extends keyof TInit\n ? [TInit['envelope' & keyof TInit]] extends [true]\n ? Envelope\n : [TInit['envelope' & keyof TInit]] extends [false | undefined]\n ? TData\n : TData | Envelope\n : TData;\n\n/** A one-shot method whose return shape never varies with per-call options. */\ntype BodyMethod =\n NoRequiredKeys extends true\n ? (args?: Entry['args'], init?: RequestOptions) => Promise\n : (args: Entry['args'], init?: RequestOptions) => Promise;\n\n/**\n * One-shot (non-SSE) method: default returns the body; `{ envelope: true }` returns\n * `{ data, headers, response }` with typed declared headers.\n */\ntype ThrowMethod =\n NoRequiredKeys extends true\n ? (\n args?: Entry['args'],\n init?: Init\n ) => Promise, Init>>\n : (\n args: Entry['args'],\n init?: Init\n ) => Promise, Init>>;\n\n/** The typed instance client: one bound method per operation plus the core members. */\nexport type Client = {\n [K in keyof Ops]: Ops[K] extends { kind: 'sse' }\n ? (NoRequiredKeys extends true\n ? (\n args?: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>\n : (\n args: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>) &\n OperationMethodIdentity\n : (Ops[K] extends { mode: 'result' } ? BodyMethod : ThrowMethod) &\n OperationMethodIdentity &\n Paginated;\n} & ClientCore;", + "/**\n * The public type surface of the client runtime — `@redocly/client-generator`'s\n * app-facing runtime module. Pure types, no runtime code (excluded from coverage).\n * The generator emits `OPERATIONS` literals typed\n * `satisfies Record` against this module, so an\n * incompatible runtime/generated pair fails the consumer's build (the semver skew guard).\n */\n\n/** How one operation parameter is sent: its location plus OpenAPI query-serialization hints. */\nexport type ParamSpec = {\n name: string;\n in: 'path' | 'query' | 'header' | 'cookie';\n style?: 'form' | 'spaceDelimited' | 'pipeDelimited' | 'deepObject';\n explode?: boolean;\n allowReserved?: boolean;\n};\n\n/** One security scheme, denormalized onto the operation (`scheme` names the spec's scheme). */\nexport type SecuritySpec =\n | { scheme: string; kind: 'bearer' | 'basic' }\n | { scheme: string; kind: 'apiKey'; name: string; in: 'header' | 'query' | 'cookie' };\n\n/**\n * How to auto-iterate a paginated operation (drives its `.pages()`/`.items()` members).\n * `nextCursor` and `items` are RFC 6901 JSON pointers into the page (response) value.\n */\nexport type PaginationSpec =\n | {\n style: 'cursor';\n /** The query param the iterator advances with the response's cursor. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the next cursor in the page. */\n nextCursor: string;\n /** Optional pointer to a boolean \"more pages\" flag — `false` stops iteration. */\n hasMore?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n style: 'offset' | 'page';\n /** The numeric query param the iterator advances. */\n param: string;\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n }\n | {\n /** RFC 8288: follow the response's `Link` header `rel=\"next\"`; stop when absent. */\n style: 'link';\n /** Optional page-size query param (recorded for tooling; never set by the runtime). */\n limitParam?: string;\n /** Pointer to the page's item array. */\n items: string;\n };\n\n/** The frozen data contract between generated code and the runtime: one operation's wire shape. */\nexport type OperationDescriptor = {\n id: string;\n method: string;\n path: string;\n tags?: readonly string[];\n params?: readonly ParamSpec[];\n /** `multipart: true` marks a typed object body serialized to FormData by the runtime. */\n body?: { contentType: string; multipart?: boolean };\n /** Defaults to `'json'` (content-type negotiation on parse). */\n responseKind?: 'json' | 'text' | 'blob' | 'void' | 'sse';\n sseDataKind?: 'json' | 'text';\n /** OR-alternatives, each an AND-set: the runtime applies the first fully-configured one. */\n security?: readonly (readonly SecuritySpec[])[];\n pagination?: PaginationSpec;\n /**\n * `'grouped'` marks an operation that takes its inputs namespaced by layer even on a\n * `argsStyle: 'flat'` client — the generator sets it where a merged call could not carry\n * one name for two layers, and the operation's own input type says the same.\n */\n argsStyle?: 'grouped';\n /**\n * Declared success-response headers for throw-mode `{ envelope: true }`.\n * `name` is the lowercased wire name; `key` is the camelCase envelope property.\n */\n responseHeaders?: readonly ResponseHeaderSpec[];\n};\n\n/** One declared response header the runtime coerces into the envelope `headers` object. */\nexport type ResponseHeaderSpec = {\n name: string;\n key: string;\n type: 'string' | 'number' | 'boolean';\n};\n\n/** A query value: scalars, arrays of scalars, or objects (serialized as deepObject brackets). */\nexport type QueryValue =\n | string\n | number\n | boolean\n | null\n | undefined\n | Array\n | Record;\n\n/** A credential: a literal, or a (possibly async) function resolved per request (refresh flows). */\nexport type TokenProvider = string | (() => string | Promise);\n\n/** Per-instance credentials, keyed by the scheme kinds the runtime can inject. */\nexport type AuthCredentials = {\n bearer?: TokenProvider;\n basic?: { username: string; password: string };\n apiKey?: Record;\n};\n\n/** Backoff shape: 'fixed' = constant delay; 'exponential' = doubling per attempt. */\nexport type RetryStrategy = 'fixed' | 'exponential';\n\n/**\n * The operation's identity, exposed to middleware for targeting (`ctx.operation`).\n * Generated clients instantiate the type parameters with the spec's literal unions\n * (`OperationId`/`OperationPath`/`OperationTag`) so a misspelled operation id in a\n * middleware comparison fails to compile; the string defaults keep every\n * spec-independent consumer (`runtime-contract.ts`, the runtime internals) working\n * with the base shape. `tags` stays mutable (`Tag[]`) so setup-contract types\n * (byte-locked to generated output) remain assignable through middleware callbacks.\n */\nexport type OperationContext<\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n> = { id: Id; path: Path; tags: Tag[] };\n\n/** The mutable request context threaded through the middleware chain. */\nexport type RequestContext = {\n url: string;\n method: string;\n headers: Record;\n body?: unknown;\n operation: Op;\n};\n\n/** The failed attempt handed to a custom `retryOn`: exactly one of `response`/`error` is set. */\nexport type RetryContext = {\n attempt: number;\n request: RequestContext;\n response?: Response;\n error?: unknown;\n};\n\n/** Opt-in retry policy; a per-call override merges field-by-field over the config policy. */\nexport type RetryConfig = {\n retries?: number;\n retryDelay?: number;\n retryStrategy?: RetryStrategy;\n jitter?: boolean;\n retryOn?: (ctx: RetryContext) => boolean | Promise;\n};\n\n/**\n * Structural stand-in for the runtime's ApiError so this module stays import-free\n * (pure types); the real `ApiError` class is assignable to it.\n */\nexport type ApiErrorLike = globalThis.Error & {\n url: string;\n status: number;\n statusText: string;\n body: unknown;\n};\n\n/** One interceptor: any subset of the three hooks. */\nexport type Middleware = {\n onRequest?: (ctx: RequestContext) => void | Promise;\n onResponse?: (\n response: Response,\n ctx: RequestContext\n ) => Response | void | Promise;\n /** Throw mode only: may map/replace the error. */\n // `globalThis.Error` so a spec schema named `Error` cannot shadow it in inline mode.\n onError?: (\n error: ApiErrorLike,\n ctx: RequestContext\n ) => globalThis.Error | Promise;\n};\n\n/** Client configuration: transport, defaults, retry policy, middleware, and credentials. */\nexport type ClientConfig = {\n serverUrl?: string;\n fetch?: typeof fetch;\n headers?:\n | Record\n | (() => Record | Promise>);\n retry?: RetryConfig;\n /** Milliseconds before a request attempt aborts (covers the body read too; each retry\n * attempt gets a fresh budget). Per-call `timeout` overrides it, `0` disables it.\n * SSE streams are long-lived by design and never inherit this value. */\n timeout?: number;\n /** Send an `Idempotency-Key` header on POST/PATCH (one stable key per logical call,\n * reused across retry attempts) — which also makes those retries safe under the\n * default retry policy. `true` generates a UUID per call; a function supplies the key. */\n idempotencyKey?: boolean | (() => string);\n /** Identifies this client to the API via an `X-Redocly-Client` header (the generator\n * bakes a default). Sent only OUTSIDE browsers — a custom header would force a CORS\n * preflight. Override with your own value, or `false` to disable. */\n clientHeader?: string | false;\n middleware?: Middleware[];\n auth?: AuthCredentials;\n /** Fixed at generate time by the generator (`'throw'` when omitted); `configure()` ignores it. */\n errorMode?: 'throw' | 'result';\n /**\n * How each call spells its inputs: `'grouped'` (the default) namespaces them by layer —\n * `{ path, query, headers, cookies, body }` — and `'flat'` takes one merged object.\n * Fixed at generate time, like `errorMode`, because it shapes the static types.\n */\n argsStyle?: 'grouped' | 'flat';\n onRequest?: Middleware['onRequest'];\n onResponse?: Middleware['onResponse'];\n onError?: Middleware['onError'];\n};\n\n/** Response readers for the per-call `parseAs` override. */\nexport type ParseAs = 'auto' | 'json' | 'text' | 'blob' | 'arrayBuffer' | 'formData' | 'stream';\n\n/** Per-call options: standard `RequestInit` plus a retry override, a timeout override\n * (`0` disables the config default), and a forced reader. */\nexport type RequestOptions = RequestInit & {\n retry?: RetryConfig;\n timeout?: number;\n /** Per-call idempotency key: a literal key, `true` to generate one, `false` to skip. */\n idempotencyKey?: string | boolean | (() => string);\n parseAs?: ParseAs;\n /**\n * Throw mode only: return `{ data, headers, response }` instead of the parsed body;\n * ignored in result mode. The explicit `| undefined` keeps the wrappers' emitted\n * `envelope: undefined` strip legal under `exactOptionalPropertyTypes`.\n */\n envelope?: boolean | undefined;\n};\n\n/** Throw-mode success envelope when `RequestOptions.envelope` is `true`. */\nexport type Envelope> = {\n data: TData;\n headers: THeaders;\n response: Response;\n};\n\n/** Per-call options for an SSE stream; reconnect defaults to true. */\nexport type SseOptions = RequestInit & { reconnect?: boolean; reconnectDelay?: number };\n\n/** A single decoded Server-Sent Event with its payload typed from the spec. */\nexport type ServerSentEvent = { event?: string; data: T; id?: string; retry?: number };\n\n/** Result-mode return shape: exactly one of `data`/`error` is set. */\nexport type Result =\n | { data: TData; error: undefined; response: Response }\n | { data: undefined; error: TError; response: Response };\n\n/**\n * The generated `Ops` type's shape: per-operation args/result, plus `kind: 'sse'` for\n * streams and, for paginated operations, `item` (the page's element type) and — on\n * result-mode clients only — `page` (the RAW page type `.pages()` yields, since\n * iteration unwraps the `Result` envelope the one-shot `result` carries).\n */\nexport type OpsShape = Record<\n string,\n {\n args: object;\n result: unknown;\n kind?: 'sse';\n item?: unknown;\n page?: unknown;\n /** Declared success-response headers for `{ envelope: true }` (camelCase keys). */\n headers?: object;\n /** Result-mode entries ignore the throw-only `envelope` option. */\n mode?: 'result';\n }\n>;\n\n/** The always-present client members (assigned after the operation loop — they win collisions). */\nexport type ClientCore = {\n /** Merge into the config; note `middleware` REPLACES the chain (use `use()` to compose). */\n configure(config: ClientConfig): void;\n /** Append interceptors (composes with baked/publisher middleware). */\n use(...middleware: Middleware[]): void;\n auth: {\n bearer(token: TokenProvider): void;\n basic(username: string, password: string): void;\n apiKey(scheme: string, value: TokenProvider): void;\n };\n};\n\n/**\n * The standard TypeScript optionality probe: `{}` has no required members, so\n * `{} extends A` is true exactly when every member of `A` is optional.\n */\n// oxlint-disable-next-line typescript/no-empty-object-type\ntype NoRequiredKeys = {} extends A ? true : false;\n\n/**\n * The page type `.pages()` yields: the RAW page declared by `page` (the generator\n * writes it only on result-mode paginated entries, whose `result` is the envelope),\n * or the method's own `result` (throw mode — already the raw page).\n */\ntype PageOf = Entry extends { page: unknown }\n ? Entry['page']\n : Entry['result'];\n\n/**\n * The auto-pagination members intersected onto a paginated method — present exactly when\n * the Ops entry declares `item` (the generator writes it only for paginated operations).\n * Args optionality mirrors the method's own; `unknown` otherwise (identity under `&`).\n * Iteration is error-mode-agnostic: `.pages()`/`.items()` yield raw pages/items, and a\n * failed page aborts iteration by throwing `ApiError`, even on result-mode clients; the\n * `onError` middleware hook (throw-mode-only) is not invoked.\n */\ntype Paginated = 'item' extends keyof Entry\n ? NoRequiredKeys extends true\n ? {\n pages(args?: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args?: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : {\n pages(args: Entry['args'], init?: RequestOptions): AsyncGenerator>;\n items(args: Entry['args'], init?: RequestOptions): AsyncGenerator;\n }\n : unknown;\n\n/**\n * The stable identity every client method carries: the SPEC operationId (also set as\n * `fn.name`, but `operationId` is the explicit, minification-proof form) — a robust\n * cache key for consumer wrappers (react-query keys and the like).\n */\nexport type OperationMethodIdentity = { readonly operationId: string };\n\n/** Declared response-header bag for an Ops entry; empty object when none are declared. */\ntype HeadersOf = 'headers' extends keyof Entry\n ? NonNullable\n : Record;\n\n/**\n * Return type of a throw-mode call: the body by default, `Envelope<…>` for a literal\n * `envelope: true`, their union when the flag is a widened `boolean`. Exact\n * `RequestOptions` stays the body — pre-envelope package-mode flat sugar typed every\n * `init` parameter as `RequestOptions`, and widening that would break upgrades without\n * a regenerate. The `keyof` presence gate keeps `{ headers }` / `{ signal }` as the body\n * (`TInit['envelope']` through `TInit & RequestOptions` would otherwise be\n * `boolean | undefined`).\n */\nexport type EnvelopeResult<\n TData,\n THeaders,\n TInit extends RequestOptions | undefined,\n> = TInit extends undefined\n ? TData\n : RequestOptions extends TInit\n ? TInit extends RequestOptions\n ? TData\n : EnvelopeResultForKnownInit\n : EnvelopeResultForKnownInit;\n\ntype EnvelopeResultForKnownInit = 'envelope' extends keyof TInit\n ? [TInit['envelope' & keyof TInit]] extends [true]\n ? Envelope\n : [TInit['envelope' & keyof TInit]] extends [false | undefined]\n ? TData\n : TData | Envelope\n : TData;\n\n/** A one-shot method whose return shape never varies with per-call options. */\ntype BodyMethod =\n NoRequiredKeys extends true\n ? (args?: Entry['args'], init?: RequestOptions) => Promise\n : (args: Entry['args'], init?: RequestOptions) => Promise;\n\n/**\n * One-shot (non-SSE) method: default returns the body; `{ envelope: true }` returns\n * `{ data, headers, response }` with typed declared headers.\n */\ntype ThrowMethod =\n NoRequiredKeys extends true\n ? (\n args?: Entry['args'],\n init?: Init\n ) => Promise, Init>>\n : (\n args: Entry['args'],\n init?: Init\n ) => Promise, Init>>;\n\n/** The typed instance client: one bound method per operation plus the core members. */\nexport type Client = {\n [K in keyof Ops]: Ops[K] extends { kind: 'sse' }\n ? (NoRequiredKeys extends true\n ? (\n args?: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>\n : (\n args: Ops[K]['args'],\n init?: SseOptions\n ) => AsyncGenerator>) &\n OperationMethodIdentity\n : (Ops[K] extends { mode: 'result' } ? BodyMethod : ThrowMethod) &\n OperationMethodIdentity &\n Paginated;\n} & ClientCore;", 'errors.ts': "/** The error thrown (throw mode) for a non-2xx response, carrying the decoded error body. */\nexport class ApiError extends Error {\n public readonly url: string;\n public readonly status: number;\n public readonly statusText: string;\n public readonly body: unknown;\n constructor(url: string, status: number, statusText: string, body: unknown) {\n super(`Request failed with status ${status}`);\n this.name = 'ApiError';\n this.url = url;\n this.status = status;\n this.statusText = statusText;\n this.body = body;\n }\n}\n\n/** The error thrown when a request attempt exceeds the configured `timeout` — carries\n * the context a log line needs (which operation, what budget, which attempt). */\nexport class TimeoutError extends Error {\n public readonly operationId: string;\n public readonly timeout: number;\n public readonly attempt: number;\n constructor(operationId: string, timeout: number, attempt: number) {\n super(`Request \"${operationId}\" timed out after ${timeout} ms (attempt ${attempt})`);\n this.name = 'TimeoutError';\n this.operationId = operationId;\n this.timeout = timeout;\n this.attempt = attempt;\n }\n}\n\n/** The error to throw for an aborted request: the caller's abort reason when it is an Error. */\n// `globalThis.Error` (not bare `Error`) so a spec schema named `Error` cannot shadow it\n// when this module is embedded alongside generated types (inline mode).\nfunction abortError(signal: AbortSignal): globalThis.Error {\n const reason = (signal as { reason?: unknown }).reason;\n if (reason instanceof Error) return reason;\n return new DOMException('The operation was aborted.', 'AbortError');\n}", 'url.ts': @@ -51,11 +51,11 @@ export const RUNTIME_SOURCES_STRIPPED = { 'sse.ts': "/**\n * A frame delimiter: two consecutive line terminators (each CR, LF, or CRLF, per the SSE\n * spec — so mixed endings like `\\n\\r\\n` are valid boundaries, not just matching pairs).\n */\nconst FRAME_DELIMITER = /(?:\\r\\n|\\r|\\n){2}/;\n\n/**\n * A terminally malformed event stream — unparseable JSON `data` or an unbounded frame.\n * A stable bad payload, not a dropped connection, so the stream never reconnects on it.\n */\nclass SseParseError extends Error {}\n\n/**\n * Consume a `text/event-stream` operation as typed events (capability module — wired\n * into `createClient`). Auto-reconnects on dropped connections, resuming from the last\n * seen event id via `Last-Event-ID` (backoff: the server's `retry:` value, then\n * `reconnectDelay`, then 1s — exponential with jitter, capped at 30s). A clean stream\n * end flushes a trailing frame and finishes; `break`/abort end the iterator cleanly.\n */\nasync function* sse(\n config: ClientConfig,\n op: OperationContext,\n prepare: () => Promise<{ url: string; init: SseOptions; body?: unknown }>,\n dataKind: 'json' | 'text' = 'text'\n): AsyncGenerator> {\n let lastEventId: string | undefined;\n let serverRetry: number | undefined;\n let failures = 0;\n while (true) {\n // Re-prepare each attempt so a refresh-style TokenProvider yields a fresh credential\n // on reconnect (the auth is baked into `url` query + `init.headers`). `reconnect`,\n // `reconnectDelay`, and `signal` come from the caller's original options unchanged.\n const { url, init, body: requestBody } = await prepare();\n const { reconnect = true, reconnectDelay, ...rest } = init;\n const signal = rest.signal ?? undefined;\n if (signal?.aborted) return;\n const headers: Record = {\n Accept: 'text/event-stream',\n ...toHeaderRecord(rest.headers),\n };\n const sendHeaders =\n lastEventId === undefined ? headers : { ...headers, 'Last-Event-ID': lastEventId };\n try {\n const { response } = await send(\n config,\n op,\n url,\n // `timeout: 0` opts the stream out of a config-level timeout — an event stream\n // is long-lived by design and must not be severed after N milliseconds.\n { ...rest, method: rest.method ?? 'GET', headers: sendHeaders, timeout: 0 },\n requestBody,\n undefined,\n {}\n );\n if (!response.ok) {\n const errorBody = await readError(response);\n throw new ApiError(url, response.status, response.statusText, errorBody);\n }\n failures = 0;\n const body = response.body;\n if (!body) return;\n const reader = body.getReader();\n const decoder = new TextDecoder();\n let buffer = '';\n try {\n while (true) {\n const { done, value } = await reader.read();\n buffer += done ? decoder.decode() : decoder.decode(value, { stream: true });\n let match: RegExpExecArray | null;\n while ((match = FRAME_DELIMITER.exec(buffer)) !== null) {\n const raw = buffer.slice(0, match.index);\n buffer = buffer.slice(match.index + match[0].length);\n const event = parseSseFrame(raw, dataKind);\n if (event) {\n if (event.id !== undefined) lastEventId = event.id;\n if (event.retry !== undefined) serverRetry = event.retry;\n yield event as ServerSentEvent;\n }\n }\n if (done) {\n // Stream closed cleanly. Flush a final event that arrived without a trailing\n // delimiter, then finish — a clean end is not a dropped connection, so do not reconnect.\n const event = buffer.length > 0 ? parseSseFrame(buffer, dataKind) : undefined;\n if (event) {\n if (event.id !== undefined) lastEventId = event.id;\n if (event.retry !== undefined) serverRetry = event.retry;\n yield event as ServerSentEvent;\n }\n return;\n }\n // Bound memory: a server that never sends a frame delimiter would otherwise\n // grow `buffer` without limit. 1 MiB is far above any real SSE frame.\n if (buffer.length > 1048576) {\n throw new SseParseError('SSE frame exceeded 1048576 characters without a delimiter');\n }\n }\n } finally {\n await reader.cancel().catch(() => undefined);\n }\n } catch (error) {\n if (signal?.aborted) return;\n // A non-OK HTTP response (4xx/5xx) or an unparseable JSON payload is a definitive\n // error, not a transient drop — surface it instead of reconnecting in a loop (a\n // stable bad payload would otherwise reconnect forever).\n if (error instanceof ApiError || error instanceof SseParseError) throw error;\n // A transport failure (connect/DNS/reset) when opening the request, or a mid-stream\n // read error, is a dropped connection: fall through to backoff/reconnect when enabled.\n if (!reconnect) throw error;\n }\n // Only the swallowed-drop path reaches here: reconnect is on and the signal not aborted.\n failures++;\n const base = serverRetry ?? reconnectDelay ?? 1000;\n const delay = Math.min(base * Math.pow(2, failures - 1), 30_000);\n try {\n await sleep(Math.random() * delay, signal);\n } catch {\n return; // sleep rejects only on abort — end the iterator cleanly\n }\n }\n}\n\n/** Parse one raw SSE frame (its lines) into an event; returns undefined for comment-only frames. */\nfunction parseSseFrame(\n raw: string,\n dataKind: 'json' | 'text'\n): ServerSentEvent | undefined {\n let event: string | undefined;\n const dataLines: string[] = [];\n let id: string | undefined;\n let retry: number | undefined;\n let sawField = false;\n for (const line of raw.split(/\\r\\n|\\n|\\r/)) {\n if (line === '' || line.startsWith(':')) continue;\n const colon = line.indexOf(':');\n const field = colon === -1 ? line : line.slice(0, colon);\n let val = colon === -1 ? '' : line.slice(colon + 1);\n if (val.startsWith(' ')) val = val.slice(1);\n sawField = true;\n if (field === 'event') event = val;\n else if (field === 'data') dataLines.push(val);\n else if (field === 'id') id = val;\n else if (field === 'retry') {\n // ASCII digits only, per the EventSource spec — anything else is ignored\n // (`Number('')` is 0 and would zero the reconnect backoff).\n if (/^\\d+$/.test(val)) retry = Number(val);\n }\n }\n if (!sawField) return undefined;\n const dataText = dataLines.join('\\n');\n let data: unknown = dataText;\n if (dataKind === 'json' && dataText !== '') {\n try {\n data = JSON.parse(dataText);\n } catch (error) {\n throw new SseParseError(\n `Failed to parse SSE event data as JSON: ${error instanceof Error ? error.message : String(error)}`\n );\n }\n }\n return { event, data, id, retry };\n}", 'create-client.ts': - "/**\n * The optional behaviors `createClientCore` can dispatch to but never statically\n * imports. The package's public `createClient` wires the full set; the future\n * inline-mode assembler wires only the capabilities a spec needs.\n */\ntype Capabilities = SendCapabilities & {\n resolveAuth?: (\n security: readonly (readonly SecuritySpec[])[],\n config: ClientConfig\n ) => Promise<{ headers: Record; query: Record }>;\n sse?: (\n config: ClientConfig,\n op: OperationContext,\n // Re-preparing per (re)connect (not a frozen url/init) lets a refresh-style\n // TokenProvider issue a fresh credential after a dropped stream reconnects.\n prepare: () => Promise<{ url: string; init: SseOptions }>,\n dataKind: 'json' | 'text'\n ) => AsyncGenerator>;\n paginate?: {\n pages: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n items: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n // The `link`-style iterators need the raw `Link` header + page URL, which the\n // parsed-page call above cannot carry (the shape mirrors paginate's `LinkPageCall`).\n pagesByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n itemsByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n };\n};\n\n/**\n * One call's inputs, namespaced by transport layer. `argsStyle: 'flat'` clients accept the\n * merged form instead (every parameter and body property at one level) — `namespaceArgs`\n * converts it to this shape before anything downstream reads it.\n */\ntype OperationArgs = {\n path?: Record;\n query?: Record;\n body?: unknown;\n headers?: Record;\n cookies?: Record;\n} & Record;\n\n/** The five layer keys, and the only top-level keys a namespaced call may carry. */\nconst LAYERS: readonly string[] = ['path', 'query', 'body', 'headers', 'cookies'];\n\n/** Where a declared parameter's `in` value puts it. */\nconst LAYER_OF: Record = {\n path: 'path',\n query: 'query',\n header: 'headers',\n cookie: 'cookies',\n};\n\n/**\n * Merged (`argsStyle: 'flat'`) args → the namespaced shape. A key that names a declared\n * parameter goes to that parameter's layer; anything else is a property of the request\n * body, which is how a flat call spells an object body. `body` stays reserved for the\n * operations a flat call cannot merge (an array, a scalar, or a binary body).\n */\nfunction namespaceArgs(op: OperationDescriptor, args: OperationArgs): OperationArgs {\n const layers: Record> = {};\n let body: unknown;\n let properties: Record | undefined;\n const layerOfParam = new Map((op.params ?? []).map((param) => [param.name, param.in]));\n for (const [key, value] of Object.entries(args)) {\n const layer = LAYER_OF[layerOfParam.get(key) ?? ''];\n if (layer !== undefined) {\n (layers[layer] ??= {})[key] = value;\n } else if (key === 'body' && op.body !== undefined) {\n body = value;\n } else if (op.body !== undefined) {\n (properties ??= {})[key] = value;\n } else {\n throw new TypeError(\n `Unknown argument \"${key}\" for operation \"${op.id}\": it names no declared parameter, and the operation takes no request body.`\n );\n }\n }\n const namespaced: OperationArgs = {};\n if (layers.path) namespaced.path = layers.path;\n // The flat surface types every query value, so the collected bag is one by construction.\n if (layers.query) namespaced.query = layers.query as Record;\n if (layers.headers) namespaced.headers = layers.headers;\n if (layers.cookies) namespaced.cookies = layers.cookies;\n if (properties !== undefined) namespaced.body = properties;\n else if (body !== undefined) namespaced.body = body;\n return namespaced;\n}\n\n/** The response reader implied by the descriptor (before any per-call `parseAs` override). */\n/**\n * The `Accept` header matching how the response will be read — a blob/text operation\n * must not ask for `application/json` (a content-negotiating server would 406 or\n * answer with a JSON error body instead of the payload). Caller `init.headers` and\n * `config.headers` still override.\n */\nfunction acceptFor(kind: ParseAs | 'void'): string {\n if (kind === 'text') return 'text/*';\n if (kind === 'blob' || kind === 'arrayBuffer' || kind === 'stream' || kind === 'formData') {\n return '*/*';\n }\n return 'application/json'; // json | auto | void\n}\n\nfunction kindFor(op: OperationDescriptor): ParseAs | 'void' {\n if (op.responseKind === 'void' || op.responseKind === 'blob' || op.responseKind === 'text') {\n return op.responseKind;\n }\n return 'auto';\n}\n\n/** The call's inputs in namespaced form, converting first on a flat-style client. */\nfunction inputOf(\n op: OperationDescriptor,\n args: OperationArgs,\n config: ClientConfig\n): OperationArgs {\n return config.argsStyle === 'flat' ? namespaceArgs(op, args) : args;\n}\n\n/** Route the namespaced args to the request pieces. */\nfunction splitArgs(op: OperationDescriptor, args: OperationArgs) {\n // An unknown layer key can only be a bug (usually flat-style args on a namespaced\n // client). TypeScript catches it, but a transpiler that skips type-checking would\n // otherwise ship a request that silently drops the value — fail the call loudly.\n for (const key of Object.keys(args)) {\n if (!LAYERS.includes(key)) {\n throw new TypeError(\n `Unknown argument \"${key}\" for operation \"${op.id}\". Inputs are grouped by layer: ${LAYERS.join(', ')}.`\n );\n }\n }\n return {\n path: args.path ?? {},\n query: args.query,\n body: args.body,\n headers: args.headers,\n cookies: args.cookies,\n };\n}\n\n/**\n * The query-serialization hints for the descriptor's query params. A spec is built only\n * when the param deviates from the OpenAPI defaults (`form` + `explode: true`, encoded),\n * and always fully resolved — so `explode: false` or `allowReserved` alone (no `style`)\n * are honored, and an omitted `explode` keeps the exploded default.\n */\nfunction queryStyles(op: OperationDescriptor): Record | undefined {\n let styles: Record | undefined;\n for (const param of op.params ?? []) {\n if (param.in !== 'query') continue;\n const deviates =\n (param.style !== undefined && param.style !== 'form') ||\n param.explode === false ||\n param.allowReserved === true;\n if (!deviates) continue;\n styles ??= {};\n styles[param.name] = {\n style: param.style ?? 'form',\n explode: param.explode ?? true,\n allowReserved: param.allowReserved,\n };\n }\n return styles;\n}\n\n/** Stringify caller-supplied extra headers, skipping empty entries. */\nfunction stringHeaders(headers: Record | undefined): Record {\n const out: Record = {};\n for (const [key, value] of Object.entries(headers ?? {})) {\n if (value !== undefined && value !== null) out[key] = String(value);\n }\n return out;\n}\n\n/** Build the request pieces an attempt needs: the final URL and the merged per-call init. */\nasync function prepareRequest(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions | SseOptions,\n caps: Capabilities\n): Promise<{ url: string; init: RequestOptions; body: unknown }> {\n const { path, query, body, headers, cookies } = splitArgs(op, args);\n const authed: { headers: Record; query: Record } =\n op.security?.length && caps.resolveAuth\n ? await caps.resolveAuth(op.security, config)\n : { headers: {}, query: {} };\n // Cookie params join the auth-injected cookies in one `Cookie` header (values\n // percent-encoded, like auth cookies). Server-side only — browsers own the header.\n const cookiePairs = Object.entries(cookies ?? {})\n .filter(([, value]) => value !== undefined && value !== null)\n .map(([cookieName, value]) => `${cookieName}=${encodeURIComponent(String(value))}`);\n if (cookiePairs.length > 0) {\n authed.headers.Cookie = [authed.headers.Cookie, ...cookiePairs].filter(Boolean).join('; ');\n }\n const fullQuery: Record = { ...query, ...authed.query };\n const url = buildUrl(\n config.serverUrl ?? '',\n substitutePath(op.path, path),\n Object.keys(fullQuery).length > 0 ? fullQuery : undefined,\n queryStyles(op)\n );\n const mergedInit: RequestOptions = {\n ...init,\n method: op.method.toUpperCase(),\n // Precedence, lowest → highest (later spreads win): injected auth → explicit\n // header params → caller `init.headers` — the caller always overrides both.\n headers: {\n ...authed.headers,\n ...stringHeaders(headers),\n ...toHeaderRecord(init.headers),\n },\n };\n return { url, init: mergedInit, body };\n}\n\n/** Coerce a single declared response header value; omit when absent or unparsable. */\nfunction coerceResponseHeader(\n raw: string | null,\n type: ResponseHeaderSpec['type']\n): string | number | boolean | undefined {\n if (raw === null) return undefined;\n if (type === 'number') {\n if (raw.trim() === '') return undefined;\n const value = Number(raw);\n return Number.isFinite(value) ? value : undefined;\n }\n if (type === 'boolean') {\n const value = raw.trim().toLowerCase();\n if (value === 'true') return true;\n if (value === 'false') return false;\n return undefined;\n }\n return raw;\n}\n\n/** Build the camelCase declared-header bag for a throw-mode envelope. */\nfunction readEnvelopeHeaders(\n response: Response,\n specs: readonly ResponseHeaderSpec[] | undefined\n): Record {\n const headers: Record = {};\n for (const spec of specs ?? []) {\n const value = coerceResponseHeader(response.headers.get(spec.name), spec.type);\n if (value !== undefined) headers[spec.key] = value;\n }\n return headers;\n}\n\n/** One non-SSE call: send, then branch on the configured error mode. */\nasync function execute(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions,\n caps: Capabilities\n): Promise {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // `parseAs` / `envelope` are client options, not fetch RequestInit fields.\n const { parseAs, envelope, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const { response, context } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (config.errorMode === 'result') {\n if (!response.ok) {\n return { data: undefined, error: await readError(response), response };\n }\n return { data: await parse(response, readKind), error: undefined, response };\n }\n if (!response.ok) {\n let error: globalThis.Error = new ApiError(\n context.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n // Thread the error through each middleware's onError in turn (each may replace it).\n for (const mw of middlewareChain(config)) {\n if (mw.onError) error = await mw.onError(error as ApiErrorLike, context);\n }\n throw error;\n }\n const data = await parse(response, readKind);\n if (envelope === true) {\n return {\n data,\n headers: readEnvelopeHeaders(response, op.responseHeaders),\n response,\n };\n }\n return data;\n}\n\n/** The paginate capability, or a descriptive throw when a paginated op is iterated unwired. */\nfunction paginateCapability(caps: Capabilities, op: OperationDescriptor) {\n if (!caps.paginate) {\n throw new Error(`Pagination capability not wired: cannot iterate operation \"${op.id}\"`);\n }\n return caps.paginate;\n}\n\n/**\n * The per-page call the iterators drive: the method itself in throw mode; in result\n * mode a wrapper that unwraps the `{ data, error, response }` envelope — the page\n * pointers are data-rooted — rethrowing a failed page as `ApiError` (iteration is\n * error-mode-agnostic; the throw-mode-only `onError` middleware hook is not invoked).\n */\nfunction pageCall(\n method: (args?: OperationArgs, init?: RequestOptions) => Promise,\n config: ClientConfig\n) {\n const callWithoutEnvelope = (args?: OperationArgs, init?: RequestOptions) => {\n if (!init || init.envelope === undefined) return method(args, init);\n const { envelope: _envelope, ...pageInit } = init;\n return method(args, pageInit);\n };\n if (config.errorMode !== 'result') return callWithoutEnvelope;\n return async (args?: OperationArgs, init?: RequestOptions) => {\n const envelope = (await callWithoutEnvelope(args, init)) as {\n data: unknown;\n error: unknown;\n response: Response;\n };\n // Failure is `!response.ok` — NOT `data === undefined`: a successful bodyless page\n // (204/void) also parses to undefined data, and a failed page's `error` can be\n // undefined too (unreadable body). The pointers then miss on the undefined data\n // and iteration stops cleanly, which is the correct semantics for an empty page.\n if (!envelope.response.ok) {\n const { response } = envelope;\n throw new ApiError(response.url, response.status, response.statusText, envelope.error);\n }\n return envelope.data;\n };\n}\n\n/**\n * The per-page call the `link`-style iterators drive: like `execute`, but returning the\n * parsed page together with the raw `Link` header and the page's own URL (for resolving\n * a relative `rel=\"next\"` target). Error-mode-agnostic like all iteration: a failed\n * page throws `ApiError` even on result-mode clients.\n */\nfunction linkPageCall(config: ClientConfig, op: OperationDescriptor, caps: Capabilities) {\n return async (args: OperationArgs = {}, init: RequestOptions = {}) => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const { parseAs, envelope: _envelope, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n const { response } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (!response.ok) {\n throw new ApiError(\n prepared.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n }\n return {\n page: await parse(response, readKind),\n linkHeader: response.headers.get('link'),\n // Some `Response` implementations leave `url` empty (mocks, constructed responses).\n url: response.url === '' ? prepared.url : response.url,\n };\n };\n}\n\n/**\n * Build a typed instance client over operation descriptors: one real bound method per\n * operation (attached by a construction-time loop — no Proxy), plus the core members\n * (`configure`/`use`/`auth`), which are assigned AFTER the loop so they win any name\n * collision with an operation. All behavior dispatches through the capability seam.\n */\nfunction createClientCore<\n Ops extends OpsShape,\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n>(\n operations: Record,\n initial: ClientConfig> = {},\n caps: Capabilities = {}\n): Client> {\n // The literal-union narrowing is a compile-time DX contract only; internally the\n // runtime works with the base (string-typed) context. One cast at this boundary —\n // `ClientConfig` is not assignable to `ClientConfig` (middleware ctx\n // params are contravariant).\n const given = initial as ClientConfig;\n // Private mutable config; the middleware array is copied so `use()` never mutates the caller's.\n const config: ClientConfig = { ...given, middleware: [...(given.middleware ?? [])] };\n const client = {} as Record;\n\n for (const [name, op] of Object.entries(operations)) {\n if (op.responseKind === 'sse') {\n const method = (given: OperationArgs = {}, init: SseOptions = {}) => {\n const args = inputOf(op, given, config);\n if (!caps.sse) {\n throw new Error(`SSE capability not wired: cannot stream operation \"${op.id}\"`);\n }\n const stream = caps.sse;\n return (async function* () {\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // A thunk the stream re-runs on every (re)connect, so auth (which `prepareRequest`\n // resolves) is refreshed per attempt rather than frozen at the first connect.\n const prepare = async () => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n return { url: prepared.url, init: prepared.init as SseOptions, body: prepared.body };\n };\n yield* stream(config, opCtx, prepare, op.sseDataKind ?? 'text');\n })();\n };\n // Consumers key off the function reference (cache keys, `OPERATIONS[fn.name]`), so\n // each closure carries its operationId instead of an inferred binding name.\n // `operationId` is the explicit, minification-proof form of the same identity\n // (the SPEC operationId — `name` is the emitted key, which a collision may rename).\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n client[name] = method;\n } else {\n // `raw` takes namespaced args; `method` is the public entry that accepts whichever\n // style the client was generated with. The iterators namespace once and then drive\n // `raw`, so a flat call is never converted twice.\n const raw = (args: OperationArgs = {}, init: RequestOptions = {}) =>\n execute(config, op, args, init, caps);\n const method = (args: OperationArgs = {}, init: RequestOptions = {}) =>\n raw(inputOf(op, args, config), init);\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n const spec = op.pagination;\n // Paginated ops keep their one-shot call and gain `.pages`/`.items`, dispatching\n // through the capability seam (like SSE: absent capability throws descriptively).\n // Iteration is error-mode-agnostic: the iterators' pointers are data-rooted, so on\n // a result-mode client (`errorMode` is fixed at construction — `configure()`\n // ignores it) each page's envelope is unwrapped before it reaches the capability.\n // A failed page aborts iteration by throwing ApiError, even on result-mode\n // clients; the `onError` middleware hook (throw-mode-only) is not invoked.\n client[name] =\n spec === undefined\n ? method\n : spec.style === 'link'\n ? Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pagesByLink(\n linkPageCall(config, op, caps),\n inputOf(op, args ?? {}, config),\n init\n ),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).itemsByLink(\n linkPageCall(config, op, caps),\n spec,\n inputOf(op, args ?? {}, config),\n init\n ),\n })\n : Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pages(\n pageCall(raw, config),\n spec,\n inputOf(op, args ?? {}, config),\n init\n ),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).items(\n pageCall(raw, config),\n spec,\n inputOf(op, args ?? {}, config),\n init\n ),\n });\n }\n }\n\n // Core members are assigned AFTER the operation loop — they win over colliding op names.\n client.configure = (next: ClientConfig): void => {\n // `errorMode` and `argsStyle` are fixed at generate time (they shape the static types);\n // flipping either at runtime would silently desync the calls from `Client`, so both\n // are ignored here.\n const { errorMode: _fixedMode, argsStyle: _fixedStyle, auth, ...rest } = next;\n Object.assign(config, rest);\n // `auth` merges into existing credentials (like the `auth.*` setters) rather than\n // replacing wholesale — so `configure({ auth: { bearer } })` keeps a previously set\n // basic/apiKey. `apiKey` merges per scheme.\n if (auth) {\n config.auth = {\n ...config.auth,\n ...auth,\n ...(auth.apiKey ? { apiKey: { ...config.auth?.apiKey, ...auth.apiKey } } : {}),\n };\n }\n };\n client.use = (...middleware: Middleware[]): void => {\n // Reassign (don't push) so a caller-provided `middleware` array isn't mutated.\n config.middleware = [...(config.middleware ?? []), ...middleware];\n };\n client.auth = {\n bearer(token: TokenProvider): void {\n config.auth = { ...config.auth, bearer: token };\n },\n basic(username: string, password: string): void {\n config.auth = { ...config.auth, basic: { username, password } };\n },\n apiKey(scheme: string, value: TokenProvider): void {\n config.auth = { ...config.auth, apiKey: { ...config.auth?.apiKey, [scheme]: value } };\n },\n };\n\n return client as Client>;\n}", + "/**\n * The optional behaviors `createClientCore` can dispatch to but never statically\n * imports. The package's public `createClient` wires the full set; the future\n * inline-mode assembler wires only the capabilities a spec needs.\n */\ntype Capabilities = SendCapabilities & {\n resolveAuth?: (\n security: readonly (readonly SecuritySpec[])[],\n config: ClientConfig\n ) => Promise<{ headers: Record; query: Record }>;\n sse?: (\n config: ClientConfig,\n op: OperationContext,\n // Re-preparing per (re)connect (not a frozen url/init) lets a refresh-style\n // TokenProvider issue a fresh credential after a dropped stream reconnects.\n prepare: () => Promise<{ url: string; init: SseOptions }>,\n dataKind: 'json' | 'text'\n ) => AsyncGenerator>;\n paginate?: {\n pages: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n items: (\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n // The `link`-style iterators need the raw `Link` header + page URL, which the\n // parsed-page call above cannot carry (the shape mirrors paginate's `LinkPageCall`).\n pagesByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n itemsByLink: (\n call: (\n args?: OperationArgs,\n init?: RequestOptions\n ) => Promise<{ page: unknown; linkHeader: string | null; url: string }>,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n ) => AsyncGenerator;\n };\n};\n\n/**\n * One call's inputs, namespaced by transport layer. `argsStyle: 'flat'` clients accept the\n * merged form instead (every parameter and body property at one level) — `namespaceArgs`\n * converts it to this shape before anything downstream reads it.\n */\ntype OperationArgs = {\n path?: Record;\n query?: Record;\n body?: unknown;\n headers?: Record;\n cookies?: Record;\n} & Record;\n\n/** The five layer keys, and the only top-level keys a namespaced call may carry. */\nconst LAYERS: readonly string[] = ['path', 'query', 'body', 'headers', 'cookies'];\n\n/** Where a declared parameter's `in` value puts it. */\nconst LAYER_OF: Record = {\n path: 'path',\n query: 'query',\n header: 'headers',\n cookie: 'cookies',\n};\n\n/**\n * Merged (`argsStyle: 'flat'`) args → the namespaced shape. A key that names a declared\n * parameter goes to that parameter's layer; anything else is a property of the request\n * body, which is how a flat call spells an object body. `body` stays reserved for the\n * operations a flat call cannot merge (an array, a scalar, or a binary body).\n */\nfunction namespaceArgs(op: OperationDescriptor, args: OperationArgs): OperationArgs {\n const layers: Record> = {};\n let body: unknown;\n let properties: Record | undefined;\n const layerOfParam = new Map((op.params ?? []).map((param) => [param.name, param.in]));\n for (const [key, value] of Object.entries(args)) {\n const layer = LAYER_OF[layerOfParam.get(key) ?? ''];\n if (layer !== undefined) {\n (layers[layer] ??= {})[key] = value;\n } else if (key === 'body' && op.body !== undefined) {\n body = value;\n } else if (op.body !== undefined) {\n (properties ??= {})[key] = value;\n } else {\n throw new TypeError(\n `Unknown argument \"${key}\" for operation \"${op.id}\": it names no declared parameter, and the operation takes no request body.`\n );\n }\n }\n const namespaced: OperationArgs = {};\n if (layers.path) namespaced.path = layers.path;\n // The flat surface types every query value, so the collected bag is one by construction.\n if (layers.query) namespaced.query = layers.query as Record;\n if (layers.headers) namespaced.headers = layers.headers;\n if (layers.cookies) namespaced.cookies = layers.cookies;\n if (properties !== undefined) namespaced.body = properties;\n else if (body !== undefined) namespaced.body = body;\n return namespaced;\n}\n\n/** The response reader implied by the descriptor (before any per-call `parseAs` override). */\n/**\n * The `Accept` header matching how the response will be read — a blob/text operation\n * must not ask for `application/json` (a content-negotiating server would 406 or\n * answer with a JSON error body instead of the payload). Caller `init.headers` and\n * `config.headers` still override.\n */\nfunction acceptFor(kind: ParseAs | 'void'): string {\n if (kind === 'text') return 'text/*';\n if (kind === 'blob' || kind === 'arrayBuffer' || kind === 'stream' || kind === 'formData') {\n return '*/*';\n }\n return 'application/json'; // json | auto | void\n}\n\nfunction kindFor(op: OperationDescriptor): ParseAs | 'void' {\n if (op.responseKind === 'void' || op.responseKind === 'blob' || op.responseKind === 'text') {\n return op.responseKind;\n }\n return 'auto';\n}\n\n/**\n * The call's inputs in namespaced form, converting first on a flat-style client. An\n * operation the generator marked `argsStyle: 'grouped'` is already namespaced — its names\n * could not be merged, so its input type never offered the flat shape.\n */\nfunction inputOf(\n op: OperationDescriptor,\n args: OperationArgs,\n config: ClientConfig\n): OperationArgs {\n const merged = config.argsStyle === 'flat' && op.argsStyle !== 'grouped';\n return merged ? namespaceArgs(op, args) : args;\n}\n\n/** Route the namespaced args to the request pieces. */\nfunction splitArgs(op: OperationDescriptor, args: OperationArgs) {\n // An unknown layer key can only be a bug (usually flat-style args on a namespaced\n // client). TypeScript catches it, but a transpiler that skips type-checking would\n // otherwise ship a request that silently drops the value — fail the call loudly.\n for (const key of Object.keys(args)) {\n if (!LAYERS.includes(key)) {\n throw new TypeError(\n `Unknown argument \"${key}\" for operation \"${op.id}\". Inputs are grouped by layer: ${LAYERS.join(', ')}.`\n );\n }\n }\n return {\n path: args.path ?? {},\n query: args.query,\n body: args.body,\n headers: args.headers,\n cookies: args.cookies,\n };\n}\n\n/**\n * The query-serialization hints for the descriptor's query params. A spec is built only\n * when the param deviates from the OpenAPI defaults (`form` + `explode: true`, encoded),\n * and always fully resolved — so `explode: false` or `allowReserved` alone (no `style`)\n * are honored, and an omitted `explode` keeps the exploded default.\n */\nfunction queryStyles(op: OperationDescriptor): Record | undefined {\n let styles: Record | undefined;\n for (const param of op.params ?? []) {\n if (param.in !== 'query') continue;\n const deviates =\n (param.style !== undefined && param.style !== 'form') ||\n param.explode === false ||\n param.allowReserved === true;\n if (!deviates) continue;\n styles ??= {};\n styles[param.name] = {\n style: param.style ?? 'form',\n explode: param.explode ?? true,\n allowReserved: param.allowReserved,\n };\n }\n return styles;\n}\n\n/** Stringify caller-supplied extra headers, skipping empty entries. */\nfunction stringHeaders(headers: Record | undefined): Record {\n const out: Record = {};\n for (const [key, value] of Object.entries(headers ?? {})) {\n if (value !== undefined && value !== null) out[key] = String(value);\n }\n return out;\n}\n\n/** Build the request pieces an attempt needs: the final URL and the merged per-call init. */\nasync function prepareRequest(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions | SseOptions,\n caps: Capabilities\n): Promise<{ url: string; init: RequestOptions; body: unknown }> {\n const { path, query, body, headers, cookies } = splitArgs(op, args);\n const authed: { headers: Record; query: Record } =\n op.security?.length && caps.resolveAuth\n ? await caps.resolveAuth(op.security, config)\n : { headers: {}, query: {} };\n // Cookie params join the auth-injected cookies in one `Cookie` header (values\n // percent-encoded, like auth cookies). Server-side only — browsers own the header.\n const cookiePairs = Object.entries(cookies ?? {})\n .filter(([, value]) => value !== undefined && value !== null)\n .map(([cookieName, value]) => `${cookieName}=${encodeURIComponent(String(value))}`);\n if (cookiePairs.length > 0) {\n authed.headers.Cookie = [authed.headers.Cookie, ...cookiePairs].filter(Boolean).join('; ');\n }\n const fullQuery: Record = { ...query, ...authed.query };\n const url = buildUrl(\n config.serverUrl ?? '',\n substitutePath(op.path, path),\n Object.keys(fullQuery).length > 0 ? fullQuery : undefined,\n queryStyles(op)\n );\n const mergedInit: RequestOptions = {\n ...init,\n method: op.method.toUpperCase(),\n // Precedence, lowest → highest (later spreads win): injected auth → explicit\n // header params → caller `init.headers` — the caller always overrides both.\n headers: {\n ...authed.headers,\n ...stringHeaders(headers),\n ...toHeaderRecord(init.headers),\n },\n };\n return { url, init: mergedInit, body };\n}\n\n/** Coerce a single declared response header value; omit when absent or unparsable. */\nfunction coerceResponseHeader(\n raw: string | null,\n type: ResponseHeaderSpec['type']\n): string | number | boolean | undefined {\n if (raw === null) return undefined;\n if (type === 'number') {\n if (raw.trim() === '') return undefined;\n const value = Number(raw);\n return Number.isFinite(value) ? value : undefined;\n }\n if (type === 'boolean') {\n const value = raw.trim().toLowerCase();\n if (value === 'true') return true;\n if (value === 'false') return false;\n return undefined;\n }\n return raw;\n}\n\n/** Build the camelCase declared-header bag for a throw-mode envelope. */\nfunction readEnvelopeHeaders(\n response: Response,\n specs: readonly ResponseHeaderSpec[] | undefined\n): Record {\n const headers: Record = {};\n for (const spec of specs ?? []) {\n const value = coerceResponseHeader(response.headers.get(spec.name), spec.type);\n if (value !== undefined) headers[spec.key] = value;\n }\n return headers;\n}\n\n/** One non-SSE call: send, then branch on the configured error mode. */\nasync function execute(\n config: ClientConfig,\n op: OperationDescriptor,\n args: OperationArgs,\n init: RequestOptions,\n caps: Capabilities\n): Promise {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // `parseAs` / `envelope` are client options, not fetch RequestInit fields.\n const { parseAs, envelope, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const { response, context } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (config.errorMode === 'result') {\n if (!response.ok) {\n return { data: undefined, error: await readError(response), response };\n }\n return { data: await parse(response, readKind), error: undefined, response };\n }\n if (!response.ok) {\n let error: globalThis.Error = new ApiError(\n context.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n // Thread the error through each middleware's onError in turn (each may replace it).\n for (const mw of middlewareChain(config)) {\n if (mw.onError) error = await mw.onError(error as ApiErrorLike, context);\n }\n throw error;\n }\n const data = await parse(response, readKind);\n if (envelope === true) {\n return {\n data,\n headers: readEnvelopeHeaders(response, op.responseHeaders),\n response,\n };\n }\n return data;\n}\n\n/** The paginate capability, or a descriptive throw when a paginated op is iterated unwired. */\nfunction paginateCapability(caps: Capabilities, op: OperationDescriptor) {\n if (!caps.paginate) {\n throw new Error(`Pagination capability not wired: cannot iterate operation \"${op.id}\"`);\n }\n return caps.paginate;\n}\n\n/**\n * The per-page call the iterators drive: the method itself in throw mode; in result\n * mode a wrapper that unwraps the `{ data, error, response }` envelope — the page\n * pointers are data-rooted — rethrowing a failed page as `ApiError` (iteration is\n * error-mode-agnostic; the throw-mode-only `onError` middleware hook is not invoked).\n */\nfunction pageCall(\n method: (args?: OperationArgs, init?: RequestOptions) => Promise,\n config: ClientConfig\n) {\n const callWithoutEnvelope = (args?: OperationArgs, init?: RequestOptions) => {\n if (!init || init.envelope === undefined) return method(args, init);\n const { envelope: _envelope, ...pageInit } = init;\n return method(args, pageInit);\n };\n if (config.errorMode !== 'result') return callWithoutEnvelope;\n return async (args?: OperationArgs, init?: RequestOptions) => {\n const envelope = (await callWithoutEnvelope(args, init)) as {\n data: unknown;\n error: unknown;\n response: Response;\n };\n // Failure is `!response.ok` — NOT `data === undefined`: a successful bodyless page\n // (204/void) also parses to undefined data, and a failed page's `error` can be\n // undefined too (unreadable body). The pointers then miss on the undefined data\n // and iteration stops cleanly, which is the correct semantics for an empty page.\n if (!envelope.response.ok) {\n const { response } = envelope;\n throw new ApiError(response.url, response.status, response.statusText, envelope.error);\n }\n return envelope.data;\n };\n}\n\n/**\n * The per-page call the `link`-style iterators drive: like `execute`, but returning the\n * parsed page together with the raw `Link` header and the page's own URL (for resolving\n * a relative `rel=\"next\"` target). Error-mode-agnostic like all iteration: a failed\n * page throws `ApiError` even on result-mode clients.\n */\nfunction linkPageCall(config: ClientConfig, op: OperationDescriptor, caps: Capabilities) {\n return async (args: OperationArgs = {}, init: RequestOptions = {}) => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n const { parseAs, envelope: _envelope, ...sendInit } = prepared.init;\n const readKind = parseAs ?? kindFor(op);\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n const { response } = await send(\n config,\n opCtx,\n prepared.url,\n sendInit,\n prepared.body,\n op.body,\n caps,\n acceptFor(readKind)\n );\n if (!response.ok) {\n throw new ApiError(\n prepared.url,\n response.status,\n response.statusText,\n await readError(response)\n );\n }\n return {\n page: await parse(response, readKind),\n linkHeader: response.headers.get('link'),\n // Some `Response` implementations leave `url` empty (mocks, constructed responses).\n url: response.url === '' ? prepared.url : response.url,\n };\n };\n}\n\n/**\n * Build a typed instance client over operation descriptors: one real bound method per\n * operation (attached by a construction-time loop — no Proxy), plus the core members\n * (`configure`/`use`/`auth`), which are assigned AFTER the loop so they win any name\n * collision with an operation. All behavior dispatches through the capability seam.\n */\nfunction createClientCore<\n Ops extends OpsShape,\n Id extends string = string,\n Path extends string = string,\n Tag extends string = string,\n>(\n operations: Record,\n initial: ClientConfig> = {},\n caps: Capabilities = {}\n): Client> {\n // The literal-union narrowing is a compile-time DX contract only; internally the\n // runtime works with the base (string-typed) context. One cast at this boundary —\n // `ClientConfig` is not assignable to `ClientConfig` (middleware ctx\n // params are contravariant).\n const given = initial as ClientConfig;\n // Private mutable config; the middleware array is copied so `use()` never mutates the caller's.\n const config: ClientConfig = { ...given, middleware: [...(given.middleware ?? [])] };\n const client = {} as Record;\n\n for (const [name, op] of Object.entries(operations)) {\n if (op.responseKind === 'sse') {\n const method = (given: OperationArgs = {}, init: SseOptions = {}) => {\n const args = inputOf(op, given, config);\n if (!caps.sse) {\n throw new Error(`SSE capability not wired: cannot stream operation \"${op.id}\"`);\n }\n const stream = caps.sse;\n return (async function* () {\n const opCtx: OperationContext = { id: op.id, path: op.path, tags: [...(op.tags ?? [])] };\n // A thunk the stream re-runs on every (re)connect, so auth (which `prepareRequest`\n // resolves) is refreshed per attempt rather than frozen at the first connect.\n const prepare = async () => {\n const prepared = await prepareRequest(config, op, args, init, caps);\n return { url: prepared.url, init: prepared.init as SseOptions, body: prepared.body };\n };\n yield* stream(config, opCtx, prepare, op.sseDataKind ?? 'text');\n })();\n };\n // Consumers key off the function reference (cache keys, `OPERATIONS[fn.name]`), so\n // each closure carries its operationId instead of an inferred binding name.\n // `operationId` is the explicit, minification-proof form of the same identity\n // (the SPEC operationId — `name` is the emitted key, which a collision may rename).\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n client[name] = method;\n } else {\n // `raw` takes namespaced args; `method` is the public entry that accepts whichever\n // style the client was generated with. The iterators namespace once and then drive\n // `raw`, so a flat call is never converted twice.\n const raw = (args: OperationArgs = {}, init: RequestOptions = {}) =>\n execute(config, op, args, init, caps);\n const method = (args: OperationArgs = {}, init: RequestOptions = {}) =>\n raw(inputOf(op, args, config), init);\n Object.defineProperty(method, 'name', { value: name });\n Object.defineProperty(method, 'operationId', { value: op.id });\n const spec = op.pagination;\n // Paginated ops keep their one-shot call and gain `.pages`/`.items`, dispatching\n // through the capability seam (like SSE: absent capability throws descriptively).\n // Iteration is error-mode-agnostic: the iterators' pointers are data-rooted, so on\n // a result-mode client (`errorMode` is fixed at construction — `configure()`\n // ignores it) each page's envelope is unwrapped before it reaches the capability.\n // A failed page aborts iteration by throwing ApiError, even on result-mode\n // clients; the `onError` middleware hook (throw-mode-only) is not invoked.\n client[name] =\n spec === undefined\n ? method\n : spec.style === 'link'\n ? Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pagesByLink(\n linkPageCall(config, op, caps),\n inputOf(op, args ?? {}, config),\n init\n ),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).itemsByLink(\n linkPageCall(config, op, caps),\n spec,\n inputOf(op, args ?? {}, config),\n init\n ),\n })\n : Object.assign(method, {\n pages: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).pages(\n pageCall(raw, config),\n spec,\n inputOf(op, args ?? {}, config),\n init\n ),\n items: (args?: OperationArgs, init?: RequestOptions) =>\n paginateCapability(caps, op).items(\n pageCall(raw, config),\n spec,\n inputOf(op, args ?? {}, config),\n init\n ),\n });\n }\n }\n\n // Core members are assigned AFTER the operation loop — they win over colliding op names.\n client.configure = (next: ClientConfig): void => {\n // `errorMode` and `argsStyle` are fixed at generate time (they shape the static types);\n // flipping either at runtime would silently desync the calls from `Client`, so both\n // are ignored here.\n const { errorMode: _fixedMode, argsStyle: _fixedStyle, auth, ...rest } = next;\n Object.assign(config, rest);\n // `auth` merges into existing credentials (like the `auth.*` setters) rather than\n // replacing wholesale — so `configure({ auth: { bearer } })` keeps a previously set\n // basic/apiKey. `apiKey` merges per scheme.\n if (auth) {\n config.auth = {\n ...config.auth,\n ...auth,\n ...(auth.apiKey ? { apiKey: { ...config.auth?.apiKey, ...auth.apiKey } } : {}),\n };\n }\n };\n client.use = (...middleware: Middleware[]): void => {\n // Reassign (don't push) so a caller-provided `middleware` array isn't mutated.\n config.middleware = [...(config.middleware ?? []), ...middleware];\n };\n client.auth = {\n bearer(token: TokenProvider): void {\n config.auth = { ...config.auth, bearer: token };\n },\n basic(username: string, password: string): void {\n config.auth = { ...config.auth, basic: { username, password } };\n },\n apiKey(scheme: string, value: TokenProvider): void {\n config.auth = { ...config.auth, apiKey: { ...config.auth?.apiKey, [scheme]: value } };\n },\n };\n\n return client as Client>;\n}", 'paginate.ts': "/**\n * Auto-pagination (capability module — wired into `createClient`, dispatched by the\n * method's `.pages()`/`.items()`): walk an operation's pages by advancing the descriptor's\n * `param` query parameter, per its `style`. The caller's args are never mutated — each\n * request gets a fresh `query` clone — and `init` is forwarded to every call.\n *\n * Iteration is error-mode-agnostic: `call` always resolves to the RAW page (on a\n * result-mode client the attachment unwraps the envelope first), so a failed page\n * aborts iteration by throwing `ApiError`, even on result-mode clients; the `onError`\n * middleware hook (throw-mode-only) is not invoked.\n */\n\n/**\n * Resolve an RFC 6901 JSON pointer (`~1` → `/`, `~0` → `~`) against a value.\n * The empty pointer is the whole document; anything else must start with `/`.\n * Returns `undefined` on any miss (bad token, absent key, non-object step) — never throws.\n */\nfunction resolvePointer(value: unknown, pointer: string): unknown {\n if (pointer === '') return value;\n if (!pointer.startsWith('/')) return undefined;\n let current = value;\n for (const token of pointer.slice(1).split('/')) {\n const key = token.replaceAll('~1', '/').replaceAll('~0', '~');\n if (Array.isArray(current)) {\n if (!/^(0|[1-9]\\d*)$/.test(key)) return undefined;\n current = current[Number(key)];\n } else if (Object(current) === current && key in (current as object)) {\n current = (current as Record)[key];\n } else {\n return undefined;\n }\n }\n return current;\n}\n\n/**\n * Iterate an operation's full page results. Every page is yielded before the stop\n * condition is evaluated, so the last page always arrives. Cursor style resumes from a\n * caller-provided `query[spec.param]`, stops when the optional `hasMore` pointer\n * resolves to `false` or when `nextCursor` resolves to `undefined`/`null`/`''`, and\n * throws if the next cursor is not a string or number, or\n * if the same cursor comes back twice in a row (infinite-loop guards). Offset/page\n * styles advance by item count / by one and stop when\n * the `items` pointer misses or the array is empty.\n */\nasync function* pages(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n if (spec.style === 'cursor') {\n let cursor: unknown = args.query?.[spec.param];\n while (true) {\n const query = { ...args.query };\n if (cursor !== undefined) query[spec.param] = cursor as QueryValue;\n const page = await call({ ...args, query }, init);\n yield page;\n // Connection-style APIs keep a non-null cursor on the last page and signal the\n // end via a boolean flag — honor it before the cursor check to skip the\n // follow-up empty request. Strictly `false`: a missing pointer falls through.\n if (spec.hasMore !== undefined && resolvePointer(page, spec.hasMore) === false) return;\n const next = resolvePointer(page, spec.nextCursor);\n if (next === undefined || next === null || next === '') return;\n if (typeof next !== 'string' && typeof next !== 'number') {\n // A fresh non-scalar cursor never compares equal, so without this guard a lying\n // server would slip past the did-not-advance check into an infinite loop.\n throw new Error(`Pagination cursor at ${spec.nextCursor} is not a string or number`);\n }\n if (next === cursor) {\n throw new Error('Pagination did not advance: operation returned the same cursor twice');\n }\n cursor = next;\n }\n } else if (spec.style === 'link') {\n // `link` iteration needs the response's `Link` header, which the parsed-page `call`\n // cannot carry — the client wires those operations to `pagesByLink` instead.\n throw new Error('link-style pagination iterates via pagesByLink');\n } else {\n // Coerce the starting position to a number: a caller may pass `query[spec.param]` as a\n // string (common from URL/form input), and `+=` on a string would concatenate. `null`\n // and `''` count as absent — `Number` would turn them into 0, but a one-shot call\n // omits the param for those values, so the iterator must not start at position 0.\n const start = args.query?.[spec.param];\n const fallback = spec.style === 'page' ? 1 : 0;\n const absent = start === undefined || start === null || start === '';\n let position = absent || Number.isNaN(Number(start)) ? fallback : Number(start);\n let previousItems: string | undefined;\n while (true) {\n const page = await call({ ...args, query: { ...args.query, [spec.param]: position } }, init);\n const pageItems = resolvePointer(page, spec.items);\n // Some APIs clamp a past-the-end offset/page to the last non-empty page instead\n // of returning an empty one — the repeated page would otherwise loop forever\n // (the position always \"advances\", so a cursor-style token check can't catch it).\n const serialized = Array.isArray(pageItems) ? JSON.stringify(pageItems) : undefined;\n if (serialized !== undefined && serialized === previousItems) {\n throw new Error('Pagination did not advance: the operation returned the same page twice');\n }\n yield page;\n if (!Array.isArray(pageItems) || pageItems.length === 0) return;\n previousItems = serialized;\n position += spec.style === 'page' ? 1 : pageItems.length;\n }\n }\n}\n\n/**\n * Iterate the operation's individual items: each page's `items` pointer, flattened.\n * A cursor-style page whose pointer misses yields nothing but pagination continues;\n * for offset/page styles a miss has already stopped `pages`.\n */\nasync function* items(\n call: (args?: OperationArgs, init?: RequestOptions) => Promise,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pages(call, spec, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}\n\n/**\n * The per-page call the `link`-style iterators drive: the parsed page plus the raw\n * `Link` header and the page's own URL (for resolving a relative `next` target).\n */\ntype LinkPageCall = (\n args?: OperationArgs,\n init?: RequestOptions\n) => Promise<{ page: unknown; linkHeader: string | null; url: string }>;\n\n/** The `rel=\"next\"` target of an RFC 8288 `Link` header, or `undefined` when absent. */\nfunction linkNext(header: string | null): string | undefined {\n if (header === null) return undefined;\n // Entries are `; rel=\"next\"`, comma-separated; the URL is inside `<>`, so split\n // on commas that precede a `<` and a comma inside a target cannot break an entry.\n for (const entry of header.split(/,\\s*(?=<)/)) {\n const target = /^\\s*<([^>]*)>(.*)$/.exec(entry);\n if (!target) continue;\n const rel = /;\\s*rel\\s*=\\s*\"?([^\";]+)\"?/i.exec(target[2]);\n // `rel` may carry several space-separated relation types (RFC 8288 §3.3).\n if (rel && rel[1].split(/\\s+/).includes('next')) return target[1];\n }\n return undefined;\n}\n\n/**\n * Iterate a `link`-style operation's pages: follow the `Link` header's `rel=\"next\"`\n * target by merging ITS query params into the next call — every page goes through the\n * same declared endpoint, so auth, middleware, and `serverUrl` handling apply\n * unchanged, and credentials can never be handed to a cross-origin `next` URL.\n * Stops when no `rel=\"next\"` is present; throws when the link does not advance\n * (the same target twice, or a self-link — an infinite-loop guard).\n */\nasync function* pagesByLink(\n call: LinkPageCall,\n args: OperationArgs = {},\n init?: RequestOptions\n): AsyncGenerator {\n let query = args.query;\n let previous: string | undefined;\n while (true) {\n const { page, linkHeader, url } = await call({ ...args, query }, init);\n yield page as TPage;\n const target = linkNext(linkHeader);\n if (target === undefined) return;\n // A relative target resolves against the page's own URL (RFC 8288 §3.1) — which may\n // itself be relative (relative `serverUrl`, mocked fetch), so both resolve against a\n // placeholder origin. It never reaches the wire: only the target's query params\n // carry into the next call.\n const pageUrl = new URL(url, 'http://relative.invalid');\n const next = new URL(target, pageUrl).toString();\n if (next === previous || next === pageUrl.toString()) {\n throw new Error('Pagination did not advance: the Link rel=\"next\" target repeats');\n }\n previous = next;\n // A repeated key (`?tag=a&tag=b`) folds into an array — the query serializer\n // expands arrays back into repeated pairs.\n const linkParams: Record = {};\n for (const [key, value] of new URL(next).searchParams) {\n const seen = linkParams[key];\n if (seen === undefined) linkParams[key] = value;\n else if (Array.isArray(seen)) seen.push(value);\n else linkParams[key] = [seen, value];\n }\n query = { ...args.query, ...linkParams };\n }\n}\n\n/** Iterate a `link`-style operation's individual items: each page's `items` pointer, flattened. */\nasync function* itemsByLink(\n call: LinkPageCall,\n spec: PaginationSpec,\n args?: OperationArgs,\n init?: RequestOptions\n): AsyncGenerator {\n for await (const page of pagesByLink(call, args, init)) {\n const pageItems = resolvePointer(page, spec.items);\n if (Array.isArray(pageItems)) yield* pageItems as TItem[];\n }\n}", 'cli.ts': - "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\ntype CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\ntype CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /**\n * Present when the operation takes a JSON request body. `merged` marks a body whose own\n * properties a flat-style call spells at the top level (the generator decides this from\n * the schema, so the CLI and the client can never disagree).\n */\n body?: { required: boolean; merged?: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\ntype CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\ntype CliWiring = {\n /** The name the CLI is invoked as, for help output only. The generated entry reads it\n * from `process.argv[1]`, so help never names a command that is not installed. */\n name: string;\n /** Credential variable prefix, constant-cased: `CAFE` gives `CAFE_TOKEN`. Fixed at\n * generation from the output file name, so renaming the binary keeps the variables\n * a published CLI already documents. A composed entry sets one per api alias. */\n envPrefix: string;\n /** The generated instance client. */\n client: Record;\n /** How that client takes its inputs. Defaults to `'grouped'`, the generated default. */\n argsStyle?: 'grouped' | 'flat';\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\ntype CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\ntype CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\ntype CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\ntype CommandSource = {\n namespace?: string;\n commands: Array;\n /** Absent = inherit the first wired source's — a root `login` shares the binary's identity. */\n wiring?: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nfunction groupSlug(group: string): string {\n return group\n .toLowerCase()\n .split(/[^a-z0-9]+/)\n .filter(Boolean)\n .join('-');\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/**\n * The parsed argv as one call input, in the style the wired client takes: grouped by layer\n * (the default) or merged into one object.\n */\nfunction callInputs(\n command: CliCommand,\n positionals: Record,\n params: Record,\n body: unknown,\n argsStyle: CliWiring['argsStyle']\n): Record | undefined {\n const inputs: Record = {};\n if (argsStyle === 'flat') {\n Object.assign(inputs, positionals, params);\n if (body !== undefined) {\n if (command.body?.merged === true) Object.assign(inputs, body as Record);\n else inputs.body = body;\n }\n } else {\n if (Object.keys(positionals).length > 0) inputs.path = positionals;\n if (Object.keys(params).length > 0) inputs.query = params;\n if (body !== undefined) inputs.body = body;\n }\n return Object.keys(inputs).length > 0 ? inputs : undefined;\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nfunction parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n // An untagged operation is only ever addressed by its bare name, so when that name is also\n // a group slug the name wins — reading it as the group would leave the command unreachable.\n // A tagged operation in the same position keeps yielding to group help: it is still\n // reachable as ` `.\n const untagged = commands.some((c) => c.group === undefined && c.name === argv[0]);\n let command: CliCommand | undefined;\n let rest: string[];\n if (!untagged && slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** `cafe-api` → `CAFE_API`: the casing of every credential variable this CLI reads. */\nfunction constantCase(value: string): string {\n return value\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix;\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${constantCase(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n name: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n name,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${name} ${topic} …`, '', 'Commands:']\n : [`Usage: ${name} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${constantCase(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${name} ${grouped ? ' ' : ''} --help for command details; ${name} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nasync function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nasync function runCli(sources: CommandSource[], argv: string[]): Promise;\nasync function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // A source without wiring inherits the first wired one, so the documented root-source\n // shape `{ commands: [login] }` works: the login shares the composed binary's identity.\n const inherited = sources.find((source) => source.wiring !== undefined)?.wiring;\n const wiringOf = (source: CommandSource): CliWiring => source.wiring ?? (inherited as CliWiring);\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = wiringOf(sources[0]);\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.name)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, wiringOf(source), argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, wiringOf(root as CommandSource), argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], name: string): string[] {\n const lines = [`Usage: ${name} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${name} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(\n commands,\n wiring.name,\n wiring.schemes ?? [],\n wiring.envPrefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n // Redaction matches these against header VALUES — so basic auth must contribute the\n // form the wire actually carries (`Basic ${base64(user:pass)}`), not just the raw\n // password, which never appears in the encoded header.\n const basic = auth.basic as { username: string; password: string } | undefined;\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(basic ? [basic.password, btoa(`${basic.username}:${basic.password}`)] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const argument = callInputs(command, positionals, params, body, wiring.argsStyle);\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n // An SSE method returns a lazy stream — drain the stubbed response so its fetch\n // actually runs and captures the request.\n if (command.sse) for await (const _event of result as AsyncIterable);\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}", + "// The generated-CLI engine: a pure argv parser plus a dispatcher that drives the\n// instance client and maps outcomes to the documented exit-code contract\n// (0 success, 1 API error, 2 auth, 3 validation, 4 usage). Node-only by nature,\n// but every effect (env, stdin, files, output) is injected through the wiring so\n// the module itself stays dependency-free and fully unit-testable; the emitted\n// entry fills the defaults with real `node:fs`/`process` bindings.\n\n/** One flag derived from a query parameter. */\ntype CliFlag = {\n /** Kebab-cased flag name (`--page-size`). */\n name: string;\n /** Original wire parameter name. */\n param: string;\n type: 'string' | 'number' | 'boolean' | 'array';\n required: boolean;\n enum?: string[];\n description?: string;\n};\n\n/** One executable command, derived from the IR at generate time. Pure data. */\ntype CliCommand = {\n /** Tag; absent = flat/untagged. */\n group?: string;\n name: string;\n summary?: string;\n method: string;\n path: string;\n /** Path params, in path-template order. Always required — that is what a path is. */\n positionals: Array<{\n name: string;\n type?: CliFlag['type'];\n description?: string;\n }>;\n flags: CliFlag[];\n /**\n * Present when the operation takes a JSON request body. `merged` marks a body whose own\n * properties a flat-style call spells at the top level (the generator decides this from\n * the schema, so the CLI and the client can never disagree).\n */\n body?: { required: boolean; merged?: boolean };\n /**\n * The content type of a request body that is NOT JSON (multipart, url-encoded, binary).\n * `--json` cannot build one, so the command is reported as library-only rather than\n * offered as if it were runnable.\n */\n unsupportedBody?: string;\n paginated?: boolean;\n /** `'grouped'` marks a command whose client method takes namespaced inputs even on a\n * flat-style client, because its merged names would collide. */\n argsStyle?: 'grouped';\n sse?: boolean;\n blob?: boolean;\n /** IR schemas for the `schema` command, serialized verbatim. */\n schemas?: { request?: unknown; response?: unknown };\n};\n\ntype CliAuthScheme = { key: string; kind: 'bearer' | 'basic' | 'apiKey' };\n\ntype CliWiring = {\n /** The name the CLI is invoked as, for help output only. The generated entry reads it\n * from `process.argv[1]`, so help never names a command that is not installed. */\n name: string;\n /** Credential variable prefix, constant-cased: `CAFE` gives `CAFE_TOKEN`. Fixed at\n * generation from the output file name, so renaming the binary keeps the variables\n * a published CLI already documents. A composed entry sets one per api alias. */\n envPrefix: string;\n /** The generated instance client. */\n client: Record;\n /** How that client takes its inputs. Defaults to `'grouped'`, the generated default. */\n argsStyle?: 'grouped' | 'flat';\n configure: (config: Record) => void;\n /** Security schemes of the API — drives env-var credential resolution. */\n schemes?: CliAuthScheme[];\n env?: Record;\n stdin?: () => string;\n readFile?: (path: string) => string;\n writeFile?: (path: string, data: Uint8Array) => void;\n stdout: (line: string) => void;\n stderr: (line: string) => void;\n};\n\ntype CliGlobals = {\n serverUrl?: string;\n format?: 'json' | 'ndjson';\n dryRun?: boolean;\n pageAll?: boolean;\n output?: string;\n token?: string;\n json?: string;\n};\n\ntype CliInvocation =\n | { kind: 'help'; topic?: CliCommand | string }\n | { kind: 'schema'; command: CliCommand }\n | {\n kind: 'run';\n command: CliCommand;\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n }\n | { kind: 'usage-error'; message: string };\n\n/**\n * A hand-written command composed NEXT TO the generated ones: the same data shape plus a\n * `handler`, so it inherits help, parsing, `schema`, and the exit-code contract. This is\n * how behavior that is not in the description (a `login`, a doctor command) joins the\n * binary without the generator ever learning what it does.\n */\ntype CustomCommand = {\n name: string;\n group?: string;\n summary?: string;\n positionals?: CliCommand['positionals'];\n flags?: CliFlag[];\n /** Returns the process exit code; throwing exits 1 with the standard error JSON. */\n handler: (context: CommandContext) => number | Promise;\n};\n\ntype CommandContext = {\n positionals: Record;\n params: Record;\n globals: CliGlobals;\n wiring: CliWiring;\n};\n\n/** One API's contribution to a composed binary: its commands behind a namespace, with its\n * OWN wiring (base URL, schemes, credentials). A namespace-less source sits at the root. */\ntype CommandSource = {\n namespace?: string;\n commands: Array;\n /** Absent = inherit the first wired source's — a root `login` shares the binary's identity. */\n wiring?: CliWiring;\n};\n\ntype ResolvedCommand = CliCommand & { handler?: CustomCommand['handler'] };\n\n/** Custom commands as the command shape the parser reads; generated ones pass through. */\nfunction normalizeCommands(commands: Array): ResolvedCommand[] {\n return commands.map((command) =>\n 'handler' in command\n ? { method: '', path: '', positionals: [], flags: [], ...command }\n : command\n );\n}\n\n/**\n * The name of a custom command that shadows another command. Rejected at startup: an\n * operator typing an operationId must never silently run something else.\n */\nfunction shadowedCommandName(commands: ResolvedCommand[]): string | undefined {\n const seen = new Map();\n for (const command of commands) {\n const key = `${command.group ?? ''}\\u0000${command.name}`;\n seen.set(key, [...(seen.get(key) ?? []), command]);\n }\n for (const clashing of seen.values()) {\n if (clashing.length > 1 && clashing.some((command) => command.handler !== undefined)) {\n return clashing[0].name;\n }\n }\n return undefined;\n}\n\nconst GLOBAL_FLAGS: Record = {\n 'server-url': { key: 'serverUrl' },\n format: { key: 'format' },\n 'dry-run': { key: 'dryRun', boolean: true },\n 'page-all': { key: 'pageAll', boolean: true },\n output: { key: 'output' },\n token: { key: 'token' },\n json: { key: 'json' },\n};\n\n/**\n * The name to print in help: the command the CLI was invoked as. A global install resolves\n * `argv[1]` to the bin itself, so its basename is exactly what the user typed. A Windows\n * `.cmd` shim, a `node dist/cafe.cli.js`, and a `tsx client.cli.ts` run all pass the script\n * path instead — printing that would name a command nobody can type, so a script extension\n * and the `.cli` marker come off: `cafe.cli.js` prints `cafe`.\n */\nfunction invokedName(scriptPath: string | undefined, fallback: string): string {\n if (scriptPath === undefined) return fallback;\n const base = scriptPath.replace(/^.*[\\\\/]/, '');\n const withoutExtension = base.replace(/\\.(mjs|cjs|js|mts|cts|ts|cmd|bat|ps1|exe)$/i, '');\n const name = withoutExtension.replace(/\\.cli$/i, '');\n return name === '' ? fallback : name;\n}\n\n/**\n * The shell-typable form of a group name: an OpenAPI tag can contain spaces (\"Some\n * multi-word tag\"), which only resolves if the user quotes it. Commands are addressed by\n * this slug; help still shows the original tag.\n */\nfunction groupSlug(group: string): string {\n return group\n .toLowerCase()\n .split(/[^a-z0-9]+/)\n .filter(Boolean)\n .join('-');\n}\n\n/** A description on ONE line: newlines in an OpenAPI description wreck help alignment. */\nfunction oneLine(text: string): string {\n return text.replace(/\\s+/g, ' ').trim();\n}\n\n/**\n * The parsed argv as one call input, in the style the wired client takes: grouped by layer\n * (the default) or merged into one object.\n */\nfunction callInputs(\n command: CliCommand,\n positionals: Record,\n params: Record,\n body: unknown,\n argsStyle: CliWiring['argsStyle']\n): Record | undefined {\n const inputs: Record = {};\n // A command the generator marked `grouped` keeps the namespaced shape even here.\n if (argsStyle === 'flat' && command.argsStyle !== 'grouped') {\n Object.assign(inputs, positionals, params);\n if (body !== undefined) {\n if (command.body?.merged === true) Object.assign(inputs, body as Record);\n else inputs.body = body;\n }\n } else {\n if (Object.keys(positionals).length > 0) inputs.path = positionals;\n if (Object.keys(params).length > 0) inputs.query = params;\n if (body !== undefined) inputs.body = body;\n }\n return Object.keys(inputs).length > 0 ? inputs : undefined;\n}\n\n/** Resolve argv against the command table. Pure — no I/O, no env. */\nfunction parseInvocation(commands: CliCommand[], argv: string[]): CliInvocation {\n if (argv.length === 0 || argv[0] === '--help') return { kind: 'help' };\n\n if (argv[0] === 'schema') {\n const command = commands.find((candidate) => candidate.name === argv[1]);\n return command\n ? { kind: 'schema', command }\n : { kind: 'usage-error', message: `Unknown command: schema ${argv[1] ?? ''}`.trim() };\n }\n\n const slugs = new Set(commands.filter((c) => c.group).map((c) => groupSlug(c.group as string)));\n // An untagged operation is only ever addressed by its bare name, so when that name is also\n // a group slug the name wins — reading it as the group would leave the command unreachable.\n // A tagged operation in the same position keeps yielding to group help: it is still\n // reachable as ` `.\n const untagged = commands.some((c) => c.group === undefined && c.name === argv[0]);\n let command: CliCommand | undefined;\n let rest: string[];\n if (!untagged && slugs.has(argv[0])) {\n if (argv[1] === '--help' || argv[1] === undefined) return { kind: 'help', topic: argv[0] };\n command = commands.find((c) => c.group && groupSlug(c.group) === argv[0] && c.name === argv[1]);\n if (!command) return { kind: 'usage-error', message: `Unknown command: ${argv[0]} ${argv[1]}` };\n rest = argv.slice(2);\n } else {\n // An ungrouped command, or a bare operationId — knowing the group shouldn't be\n // required when the name alone is unambiguous.\n const named = commands.filter((c) => c.name === argv[0]);\n command =\n named.find((c) => c.group === undefined) ?? (named.length === 1 ? named[0] : undefined);\n if (!command) {\n const ambiguous = named.length > 1;\n return {\n kind: 'usage-error',\n message: ambiguous\n ? `Ambiguous command: ${argv[0]} — prefix it with its group (${named\n .map((c) => groupSlug(c.group as string))\n .join(', ')})`\n : `Unknown command: ${argv[0]}`,\n };\n }\n rest = argv.slice(1);\n }\n if (rest.includes('--help')) return { kind: 'help', topic: command };\n\n const positionals: Record = {};\n const params: Record = {};\n const globals: CliGlobals = {};\n let positionalIndex = 0;\n for (let index = 0; index < rest.length; index++) {\n const token = rest[index];\n if (!token.startsWith('--')) {\n const slot = command.positionals[positionalIndex++];\n if (!slot) return { kind: 'usage-error', message: `Unexpected argument: ${token}` };\n positionals[slot.name] = token;\n continue;\n }\n const equals = token.indexOf('=');\n const flagName = equals === -1 ? token.slice(2) : token.slice(2, equals);\n const inlineValue = equals === -1 ? undefined : token.slice(equals + 1);\n const takeValue = (): string | undefined =>\n inlineValue !== undefined ? inlineValue : rest[++index];\n\n const global = GLOBAL_FLAGS[flagName];\n if (global) {\n if (global.boolean) {\n (globals[global.key] as boolean) = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (global.key === 'format' && value !== 'json' && value !== 'ndjson') {\n return { kind: 'usage-error', message: `--format must be one of: json, ndjson` };\n }\n (globals[global.key] as string) = value;\n continue;\n }\n\n const flag = command.flags.find((candidate) => candidate.name === flagName);\n if (!flag) return { kind: 'usage-error', message: `Unknown flag: --${flagName}` };\n if (flag.type === 'boolean') {\n params[flag.param] = true;\n continue;\n }\n const value = takeValue();\n if (value === undefined) {\n return { kind: 'usage-error', message: `Flag --${flagName} expects a value` };\n }\n if (flag.enum && !flag.enum.includes(value)) {\n return {\n kind: 'usage-error',\n message: `--${flagName} must be one of: ${flag.enum.join(', ')}`,\n };\n }\n if (flag.type === 'number') {\n const numeric = Number(value);\n if (Number.isNaN(numeric)) {\n return { kind: 'usage-error', message: `--${flagName} expects a number, got \"${value}\"` };\n }\n params[flag.param] = numeric;\n } else if (flag.type === 'array') {\n const existing = params[flag.param];\n params[flag.param] = Array.isArray(existing) ? [...existing, value] : [value];\n } else {\n params[flag.param] = value;\n }\n }\n\n for (const slot of command.positionals) {\n if (!(slot.name in positionals)) {\n return { kind: 'usage-error', message: `Missing required argument: <${slot.name}>` };\n }\n }\n for (const flag of command.flags) {\n if (flag.required && !(flag.param in params)) {\n return { kind: 'usage-error', message: `Missing required flag: --${flag.name}` };\n }\n }\n if (globals.json !== undefined && !command.body) {\n return { kind: 'usage-error', message: `${command.name} does not accept a request body` };\n }\n if (command.body?.required && globals.json === undefined) {\n return {\n kind: 'usage-error',\n message: `${command.name} requires a request body: pass --json '', --json @file.json, or --json @-`,\n };\n }\n if (globals.pageAll && !command.paginated) {\n return {\n kind: 'usage-error',\n message: `${command.name} is not paginated; --page-all only applies to paginated operations`,\n };\n }\n return { kind: 'run', command, positionals, params, globals };\n}\n\n/** `cafe-api` → `CAFE_API`: the casing of every credential variable this CLI reads. */\nfunction constantCase(value: string): string {\n return value\n .replace(/[^A-Za-z0-9]+/g, '_')\n .replace(/([a-z0-9])([A-Z])/g, '$1_$2')\n .toUpperCase();\n}\n\nfunction resolveAuth(wiring: CliWiring, token: string | undefined): Record {\n const env = wiring.env ?? {};\n const prefix = wiring.envPrefix;\n const auth: Record = {};\n for (const scheme of wiring.schemes ?? []) {\n if (scheme.kind === 'bearer') {\n const value = token ?? env[`${prefix}_TOKEN`];\n if (value !== undefined) auth.bearer = value;\n } else if (scheme.kind === 'basic') {\n const username = env[`${prefix}_USERNAME`];\n const password = env[`${prefix}_PASSWORD`];\n if (username !== undefined && password !== undefined) auth.basic = { username, password };\n } else {\n const value = env[`${prefix}_API_KEY_${constantCase(scheme.key)}`];\n if (value !== undefined) {\n auth.apiKey = {\n ...(auth.apiKey as Record | undefined),\n [scheme.key]: value,\n };\n }\n }\n }\n return auth;\n}\n\n/**\n * One command's complete contract as plain data — what `schema ` prints. It has\n * to carry the parameters: 'GET' operations have no body, so without them the output says\n * nothing a caller could act on, and the only alternative is scraping `--help`, which is\n * prose written for humans.\n */\nfunction commandContract(command: CliCommand): Record {\n return {\n operationId: command.name,\n ...(command.group === undefined ? {} : { group: groupSlug(command.group) }),\n ...(command.summary === undefined ? {} : { summary: oneLine(command.summary) }),\n ...(command.method === '' ? {} : { method: command.method }),\n ...(command.path === '' ? {} : { path: command.path }),\n parameters: {\n path: command.positionals.map((positional) => ({\n name: positional.name,\n type: positional.type ?? 'string',\n required: true,\n ...(positional.description === undefined\n ? {}\n : { description: oneLine(positional.description) }),\n })),\n // `name` is what you type (`--max-total`); `param` is the wire name it becomes.\n query: command.flags.map((flag) => ({\n name: flag.name,\n param: flag.param,\n type: flag.type,\n required: flag.required,\n ...(flag.enum === undefined ? {} : { enum: flag.enum }),\n ...(flag.description === undefined ? {} : { description: oneLine(flag.description) }),\n })),\n },\n ...(command.body === undefined ? {} : { body: command.body }),\n ...(command.unsupportedBody === undefined ? {} : { unsupportedBody: command.unsupportedBody }),\n ...(command.paginated === true ? { paginated: true } : {}),\n ...(command.sse === true ? { sse: true } : {}),\n ...(command.blob === true ? { blob: true } : {}),\n ...(command.schemas ?? {}),\n };\n}\n\nfunction renderHelp(\n commands: CliCommand[],\n name: string,\n schemes: CliAuthScheme[],\n prefix: string,\n topic?: CliCommand | string\n): string[] {\n if (topic !== undefined && typeof topic !== 'string') {\n const command = topic;\n const usage = [\n name,\n ...(command.group ? [groupSlug(command.group)] : []),\n command.name,\n ...command.positionals.map((slot) => `<${slot.name}>`),\n ...(command.flags.length > 0 ? ['[flags]'] : []),\n ...(command.body ? [\"--json '' | @file | @-\"] : []),\n ].join(' ');\n const lines = [`Usage: ${usage}`];\n if (command.summary) lines.push('', command.summary);\n if (command.unsupportedBody !== undefined) {\n lines.push(\n '',\n `This operation takes a ${command.unsupportedBody} body, which the CLI cannot build — call it through the generated client instead.`\n );\n }\n if (command.flags.length > 0) {\n lines.push('', 'Flags:');\n for (const flag of command.flags) {\n const choices = flag.enum ? ` (one of: ${flag.enum.join(', ')})` : '';\n const required = flag.required ? ' [required]' : '';\n lines.push(\n ` --${flag.name} <${flag.type}>${choices}${required} ${oneLine(flag.description ?? '')}`.trimEnd()\n );\n }\n }\n return lines;\n }\n const scope =\n typeof topic === 'string'\n ? commands.filter((c) => c.group && groupSlug(c.group) === topic)\n : commands;\n const lines =\n typeof topic === 'string'\n ? [`Usage: ${name} ${topic} …`, '', 'Commands:']\n : [`Usage: ${name} [group] …`, '', 'Commands:'];\n const seenGroups = new Set();\n const grouped = commands.some((c) => c.group);\n for (const command of scope) {\n if (typeof topic !== 'string' && command.group) {\n const slug = groupSlug(command.group);\n if (seenGroups.has(slug)) continue;\n seenGroups.add(slug);\n // The slug is what you type; the tag is what you recognize.\n const title = slug === command.group ? '' : ` (${command.group})`;\n lines.push(` ${slug} ${title}`);\n continue;\n }\n lines.push(\n ` ${[command.group === undefined ? undefined : groupSlug(command.group), command.name]\n .filter(Boolean)\n .join(' ')} ${oneLine(command.summary ?? '')}`.trimEnd()\n );\n }\n // Flags that apply to every command, and the env vars credentials come from: a flag\n // absent from --help may as well not exist — and one this API cannot use should not be\n // listed at all, since the operator would spend the debugging session on their token.\n const kinds = new Set(schemes.map((scheme) => scheme.kind));\n const credentials = [\n ...(kinds.has('bearer') ? [`${prefix}_TOKEN`] : []),\n ...(kinds.has('basic') ? [`${prefix}_USERNAME`, `${prefix}_PASSWORD`] : []),\n ...schemes\n .filter((scheme) => scheme.kind === 'apiKey')\n .map((scheme) => `${prefix}_API_KEY_${constantCase(scheme.key)}`),\n ];\n lines.push(\n '',\n 'Global flags:',\n ' --server-url Override the baked server URL',\n ' --format Output format',\n ' --dry-run Print the prepared request without sending it',\n ' --page-all Follow pagination, one JSON page per line',\n ' --output Write the response body to a file (required for binary)',\n ...(kinds.has('bearer') ? [' --token Bearer token'] : []),\n ` --json Request body`,\n ...(credentials.length > 0 ? ['', 'Environment:', ` ${credentials.join(', ')}`] : []),\n '',\n `Run ${name} ${grouped ? ' ' : ''} --help for command details; ${name} schema prints its schemas.`\n );\n return lines;\n}\n\nfunction loadBody(source: string, wiring: CliWiring): unknown {\n const raw =\n source === '@-'\n ? (wiring.stdin ?? (() => ''))()\n : source.startsWith('@')\n ? (wiring.readFile ?? (() => ''))(source.slice(1))\n : source;\n return JSON.parse(raw);\n}\n\n/** Replace header values containing a known credential with `***`. */\nfunction redactHeaders(headers: Record, secrets: string[]): Record {\n const redacted: Record = {};\n for (const [name, value] of Object.entries(headers)) {\n redacted[name] = secrets.some((secret) => secret !== '' && value.includes(secret))\n ? '***'\n : value;\n }\n return redacted;\n}\n\n/** Parse argv, resolve env auth, dispatch through the client, print, return the exit code. */\nasync function runCli(\n commands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise;\n/** The composed form: one binary over several sources, each namespaced with its own wiring. */\nasync function runCli(sources: CommandSource[], argv: string[]): Promise;\nasync function runCli(\n commandsOrSources: Array | CommandSource[],\n wiringOrArgv: CliWiring | string[],\n argv?: string[]\n): Promise {\n if (Array.isArray(wiringOrArgv)) {\n return runSources(commandsOrSources as CommandSource[], wiringOrArgv);\n }\n return runSingle(\n commandsOrSources as Array,\n wiringOrArgv,\n argv ?? []\n );\n}\n\n/** Route the first token to its source; the namespace-less source owns the root. */\nasync function runSources(sources: CommandSource[], argv: string[]): Promise {\n // A source without wiring inherits the first wired one, so the documented root-source\n // shape `{ commands: [login] }` works: the login shares the composed binary's identity.\n const inherited = sources.find((source) => source.wiring !== undefined)?.wiring;\n const wiringOf = (source: CommandSource): CliWiring => source.wiring ?? (inherited as CliWiring);\n // Top-level output goes through the first source: with a root source that is the one\n // carrying the shared commands, otherwise the first API listed.\n const top = wiringOf(sources[0]);\n const fail = (code: number, message: string): number => {\n top.stderr(JSON.stringify({ error: { code, message } }));\n return code;\n };\n const namespaced = sources.filter(\n (source): source is CommandSource & { namespace: string } => source.namespace !== undefined\n );\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined) {\n const clash = root.commands.find((command) =>\n namespaced.some((source) => source.namespace === command.name)\n );\n if (clash !== undefined) {\n return fail(\n 4,\n `Root command \"${clash.name}\" collides with the \"${clash.name}\" namespace — rename one of them.`\n );\n }\n }\n if (argv.length === 0 || argv[0] === '--help') {\n for (const line of renderComposedHelp(sources, top.name)) top.stdout(line);\n return 0;\n }\n const source = namespaced.find((candidate) => candidate.namespace === argv[0]);\n if (source !== undefined) return runSingle(source.commands, wiringOf(source), argv.slice(1));\n const rootTakes =\n root !== undefined &&\n (argv[0] === 'schema' ||\n root.commands.some(\n (command) =>\n command.name === argv[0] ||\n (command.group !== undefined && groupSlug(command.group) === argv[0])\n ));\n if (rootTakes)\n return runSingle((root as CommandSource).commands, wiringOf(root as CommandSource), argv);\n return fail(\n 4,\n `Unknown command: ${argv[0]} — expected an API namespace (${namespaced\n .map((candidate) => candidate.namespace)\n .join(', ')})${root !== undefined ? ' or a root command' : ''}`\n );\n}\n\n/** The composed top-level help: namespaces, root commands, and how to descend. */\nfunction renderComposedHelp(sources: CommandSource[], name: string): string[] {\n const lines = [`Usage: ${name} …`, '', 'APIs:'];\n for (const source of sources) {\n if (source.namespace !== undefined) lines.push(` ${source.namespace}`);\n }\n const root = sources.find((source) => source.namespace === undefined);\n if (root !== undefined && root.commands.length > 0) {\n lines.push('', 'Commands:');\n for (const command of root.commands) {\n lines.push(` ${command.name} ${oneLine(command.summary ?? '')}`.trimEnd());\n }\n }\n lines.push('', `Run ${name} --help for that API's commands.`);\n return lines;\n}\n\nasync function runSingle(\n rawCommands: Array,\n wiring: CliWiring,\n argv: string[]\n): Promise {\n const { stdout, stderr } = wiring;\n const commands = normalizeCommands(rawCommands);\n const shadowed = shadowedCommandName(commands);\n if (shadowed !== undefined) {\n stderr(\n JSON.stringify({\n error: {\n code: 4,\n message: `Custom command \"${shadowed}\" collides with another command of the same name — rename it.`,\n },\n })\n );\n return 4;\n }\n const fail = (code: number, error: Record): number => {\n stderr(JSON.stringify({ error: { code, ...error } }));\n return code;\n };\n\n const invocation = parseInvocation(commands, argv);\n if (invocation.kind === 'usage-error') return fail(4, { message: invocation.message });\n if (invocation.kind === 'help') {\n for (const line of renderHelp(\n commands,\n wiring.name,\n wiring.schemes ?? [],\n wiring.envPrefix,\n invocation.topic\n ))\n stdout(line);\n return 0;\n }\n if (invocation.kind === 'schema') {\n stdout(JSON.stringify(commandContract(invocation.command), null, 2));\n return 0;\n }\n\n const { command, positionals, params, globals } = invocation;\n // A credential the user passed explicitly must never be dropped in silence: without a\n // bearer scheme the request would go out unauthenticated and come back 401, which reads\n // as \"my token is wrong\" rather than \"that flag does nothing here\".\n const schemes = wiring.schemes ?? [];\n if (globals.token !== undefined && !schemes.some((scheme) => scheme.kind === 'bearer')) {\n const declared = schemes.map((scheme) => `${scheme.key} (${scheme.kind})`).join(', ');\n return fail(4, {\n message:\n `--token is a bearer credential, and this API declares no bearer scheme. ` +\n (declared === '' ? 'It declares no security schemes at all.' : `It accepts: ${declared}.`),\n });\n }\n if (command.blob && globals.output === undefined) {\n return fail(4, {\n message: `${command.name} downloads a file: pass --output `,\n operationId: command.name,\n });\n }\n let body: unknown;\n if (globals.json !== undefined) {\n try {\n body = loadBody(globals.json, wiring);\n } catch (error) {\n return fail(4, {\n message: `Invalid --json body: ${(error as Error).message}`,\n operationId: command.name,\n });\n }\n }\n\n const auth = resolveAuth(wiring, globals.token);\n if (Object.keys(auth).length > 0) wiring.configure({ auth });\n if (globals.serverUrl !== undefined) wiring.configure({ serverUrl: globals.serverUrl });\n\n // Redaction matches these against header VALUES — so basic auth must contribute the\n // form the wire actually carries (`Basic ${base64(user:pass)}`), not just the raw\n // password, which never appears in the encoded header.\n const basic = auth.basic as { username: string; password: string } | undefined;\n const secrets = [\n ...(typeof auth.bearer === 'string' ? [auth.bearer] : []),\n ...(basic ? [basic.password, btoa(`${basic.username}:${basic.password}`)] : []),\n ...Object.values((auth.apiKey as Record | undefined) ?? {}),\n ];\n let captured: Record | undefined;\n if (globals.dryRun) {\n wiring.configure({\n fetch: async (\n url: string,\n init: { method?: string; headers?: Record; body?: unknown }\n ) => {\n captured = {\n url,\n method: init.method,\n headers: redactHeaders(init.headers ?? {}, secrets),\n ...(init.body !== undefined && init.body !== null ? { body: String(init.body) } : {}),\n };\n return new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } });\n },\n });\n }\n\n const argument = callInputs(command, positionals, params, body, wiring.argsStyle);\n\n // The client's methods are typed per-operation; the dispatcher only needs \"callable\n // by name\", so one localized widening here keeps the emitted wiring cast-free.\n const methods = wiring.client as Record;\n try {\n const resolved = command as ResolvedCommand;\n if (resolved.handler !== undefined) {\n return await resolved.handler({ positionals, params, globals, wiring });\n }\n if (globals.pageAll && !globals.dryRun) {\n const paginated = methods[command.name] as {\n pages: (variables?: unknown) => AsyncIterable;\n };\n for await (const page of paginated.pages(argument)) stdout(JSON.stringify(page));\n return 0;\n }\n const method = methods[command.name] as (variables?: unknown) => Promise;\n const result = await method(argument);\n if (globals.dryRun) {\n // An SSE method returns a lazy stream — drain the stubbed response so its fetch\n // actually runs and captures the request.\n if (command.sse) for await (const _event of result as AsyncIterable);\n stdout(JSON.stringify(captured, null, 2));\n return 0;\n }\n if (command.sse) {\n for await (const event of result as AsyncIterable) stdout(JSON.stringify(event));\n return 0;\n }\n if (command.blob) {\n const bytes = new Uint8Array(await (result as Blob).arrayBuffer());\n (wiring.writeFile ?? (() => {}))(globals.output as string, bytes);\n stdout(JSON.stringify({ saved: globals.output, bytes: bytes.length }));\n return 0;\n }\n if (result !== undefined && result !== null) stdout(JSON.stringify(result, null, 2));\n return 0;\n } catch (error) {\n const thrown = error as Error & { status?: number; issues?: unknown };\n const detail = {\n message: thrown.message,\n operationId: command.name,\n ...(thrown.status !== undefined ? { status: thrown.status } : {}),\n ...(thrown.issues !== undefined ? { issues: thrown.issues } : {}),\n };\n if (thrown.name === 'ZodValidationError') return fail(3, detail);\n if (thrown.name === 'ApiError' && (thrown.status === 401 || thrown.status === 403)) {\n return fail(2, detail);\n }\n return fail(1, detail);\n }\n}", } as const; export type RuntimeModuleName = keyof typeof RUNTIME_SOURCES; @@ -135,6 +135,7 @@ export const RUNTIME_DECLARED_NAMES = [ 'execute', 'groupSlug', 'inputOf', + 'invokedName', 'isConfigured', 'items', 'itemsByLink', diff --git a/packages/client-generator/src/emitters/tanstack-query.ts b/packages/client-generator/src/emitters/tanstack-query.ts index 4e17f48dd8..4e0a43fc06 100644 --- a/packages/client-generator/src/emitters/tanstack-query.ts +++ b/packages/client-generator/src/emitters/tanstack-query.ts @@ -201,7 +201,7 @@ function nextPageSource( ): string { const advance = paramsAccess(spec.param); // Where the caller's own starting value lives, in the sdk's spelling for a query param. - const given = argsStyle === 'flat' ? `vars.${advance}` : `vars.query?.${advance}`; + const given = argsStyle === 'flat' ? memberAccess('vars', spec.param) : `vars.query?.${advance}`; if (spec.style === 'cursor') { const stopEarly = spec.hasMore === undefined @@ -290,6 +290,14 @@ function paramsAccess(name: string): string { return isSafeIdentifier(name) ? name : `[${safeIdent(name)}]`; } +/** + * `.name`, or `["wire-name"]` when the name is not an identifier — the dot form + * would be a syntax error there. (After `?.` either form appends directly.) + */ +function memberAccess(base: string, name: string): string { + return isSafeIdentifier(name) ? `${base}.${name}` : `${base}[${safeIdent(name)}]`; +} + /** An RFC 6901 pointer as an optional property chain: `/page/endCursor` → `.page?.endCursor`. */ function pointerChain(pointer: string): string { const keys = pointer diff --git a/packages/client-generator/src/generators/__tests__/cli.test.ts b/packages/client-generator/src/generators/__tests__/cli.test.ts index 9eed3109b0..b33eca6fed 100644 --- a/packages/client-generator/src/generators/__tests__/cli.test.ts +++ b/packages/client-generator/src/generators/__tests__/cli.test.ts @@ -120,8 +120,9 @@ describe('naming', () => { emit: {}, })[0].content; // The prefix is generated, so installing the file under another bin keeps the - // variables; the displayed name follows whatever the operator actually typed. + // variables; the displayed name follows whatever the operator actually typed, with the + // generated name standing in when `argv[1]` is a script path rather than the command. expect(out).toContain('envPrefix: "OPENAPI_CLIENT"'); - expect(out).toContain('name: basename(process.argv[1] ?? "openapi.client")'); + expect(out).toContain('name: invokedName(process.argv[1], "openapi.client")'); }); }); diff --git a/packages/client-generator/src/generators/__tests__/python.test.ts b/packages/client-generator/src/generators/__tests__/python.test.ts index 18b6750a8b..8f74b9036b 100644 --- a/packages/client-generator/src/generators/__tests__/python.test.ts +++ b/packages/client-generator/src/generators/__tests__/python.test.ts @@ -516,6 +516,59 @@ describe('pythonGenerator parity features', () => { expect(out).toContain('-> Iterator[OrderPage]:'); }); + it('an iterator takes the path parameters and substitutes them, like the call does', () => { + // Without this the iterator requested the template literally (`/orders/{orderId}/items`) + // and the caller had no argument to pass the value in. + const out = pythonGenerator({ + model: { + title: 'Nested', + version: '1.0.0', + serverUrl: 'https://api.example.com', + schemas: [], + securitySchemes: [], + services: [ + { + name: 'Orders', + operations: [ + { + name: 'listOrderItems', + specName: 'listOrderItems', + method: 'get', + path: '/orders/{orderId}/items', + tags: [], + pathParams: [{ name: 'orderId', in: 'path', required: true, schema: STRING }], + queryParams: [{ name: 'cursor', in: 'query', required: false, schema: STRING }], + headerParams: [], + cookieParams: [], + security: [], + paginationExtension: { + style: 'cursor', + cursorParam: 'cursor', + nextCursor: '/next', + items: '/items', + }, + successResponses: [ + { + status: '200', + contentType: 'application/json', + schema: { kind: 'object', properties: [] }, + }, + ], + errorResponses: [], + }, + ], + }, + ], + } as unknown as ApiModel, + outputPath: '/tmp/client.ts', + emit: {}, + outputMode: 'single', + })[0].content; + expect(out).toContain('def list_order_items_pages(self, order_id: str, *, cursor:'); + expect(out).toContain('def list_order_items_items(self, order_id: str, *, cursor:'); + expect(out).toContain('url = build_url(self._server_url, op["path"], {"orderId": order_id})'); + }); + it('SSE operations stream typed events; multipart bodies route through to_multipart', () => { const out = generate(); expect(out).toContain('def stream_events('); diff --git a/packages/client-generator/src/generators/python/index.ts b/packages/client-generator/src/generators/python/index.ts index a82aed7b33..20cb9e99fc 100644 --- a/packages/client-generator/src/generators/python/index.ts +++ b/packages/client-generator/src/generators/python/index.ts @@ -630,11 +630,21 @@ function writePaginationWrappers( ): void { const success = successSchema(op); const pageType = success === undefined ? 'Any' : pythonType(success, dateType); - const iterNames = uniqueIdentifiers( - op.queryParams.map((param) => param.name), + // 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 queryArgs = op.queryParams.map((param, index) => ({ param, python: iterNames[index] })); + 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); @@ -645,7 +655,7 @@ function writePaginationWrappers( 'timeout: Optional[float] = None', 'retry: Optional[Dict[str, Any]] = None', ]; - const signature = ['self', '*', ...kwargs].join(', '); + 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'; @@ -661,7 +671,10 @@ function writePaginationWrappers( 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)'); - printer.line('url = build_url(self._server_url, op["path"], {})'); + const pathDict = pathArgs + .map(({ param, python }) => `${JSON.stringify(param.name)}: ${python}`) + .join(', '); + printer.line(`url = build_url(self._server_url, op["path"], {${pathDict}})`); printer.line( `response = ${awaitKw}${isAsync ? 'send_async' : 'send'}(self._http, self._config, op, url, method=op["method"], ` + 'headers={**auth_headers, **(headers or {})}, params={**page_params, **auth_query}, ' + diff --git a/packages/client-generator/src/runtime/__tests__/cli.test.ts b/packages/client-generator/src/runtime/__tests__/cli.test.ts index c907732a51..7485f1ec51 100644 --- a/packages/client-generator/src/runtime/__tests__/cli.test.ts +++ b/packages/client-generator/src/runtime/__tests__/cli.test.ts @@ -1,4 +1,5 @@ import { + invokedName, parseInvocation, runCli, type CliCommand, @@ -748,3 +749,20 @@ describe('help output', () => { }); }); }); + +describe('invokedName', () => { + it('names the command the CLI was invoked as, not the script file', () => { + // A global install: `argv[1]` IS the bin, so its basename is what the user typed. + expect(invokedName('/usr/local/bin/cafe', 'client')).toBe('cafe'); + expect(invokedName('/usr/local/bin/mycafe', 'client')).toBe('mycafe'); + // A Windows shim, a `node dist/cafe.cli.js`, and a `tsx client.cli.ts` run all pass the + // script path — printing that would name a command nobody can type. + expect(invokedName('C:\\project\\dist\\cafe.cli.js', 'client')).toBe('cafe'); + expect(invokedName('/project/src/client.cli.ts', 'client')).toBe('client'); + expect(invokedName('/project/bin/cafe.cmd', 'client')).toBe('cafe'); + expect(invokedName('/project/dist/cafe.mjs', 'client')).toBe('cafe'); + // Nothing to read, or nothing left after trimming: the generated name stands in. + expect(invokedName(undefined, 'client')).toBe('client'); + expect(invokedName('/project/.js', 'client')).toBe('client'); + }); +}); diff --git a/packages/client-generator/src/runtime/__tests__/create-client.test.ts b/packages/client-generator/src/runtime/__tests__/create-client.test.ts index b68c258731..cadc89a3a8 100644 --- a/packages/client-generator/src/runtime/__tests__/create-client.test.ts +++ b/packages/client-generator/src/runtime/__tests__/create-client.test.ts @@ -186,6 +186,34 @@ describe('createClientCore', () => { expect((calls[2].init.headers as Record).Accept).toBe('application/json'); }); + it('a flat client still takes namespaced args for an operation marked grouped', async () => { + // A merged call cannot carry one name for two layers, so the generator marks that + // operation `argsStyle: 'grouped'` and types it that way — the runtime must agree. + const ops = { + getThing: { + id: 'getThing', + method: 'GET', + path: '/things/{id}', + params: [ + { name: 'id', in: 'path' as const }, + { name: 'id', in: 'query' as const }, + ], + argsStyle: 'grouped' as const, + }, + }; + const { calls, fetchImpl } = spy([jsonOk({ ok: true })]); + const client = createClientCore<{ + getThing: { args: Record; result: unknown }; + }>(ops, { + serverUrl: 'https://x', + argsStyle: 'flat', + fetch: fetchImpl, + }); + await client.getThing({ path: { id: 'p1' }, query: { id: 7 } }); + // Both values reach the wire, each in its own place. + expect(calls[0].url).toBe('https://x/things/p1?id=7'); + }); + it('rejects an unknown top-level argument key (flat-style shape passed to a grouped call)', async () => { const client = createClientCore(OPS, { serverUrl: 'https://x' }); await expect(client.getOrder({ path: { orderId: 'o1' }, limit: 10 } as never)).rejects.toThrow( diff --git a/packages/client-generator/src/runtime/cli.ts b/packages/client-generator/src/runtime/cli.ts index d48ce5c801..575d842a22 100644 --- a/packages/client-generator/src/runtime/cli.ts +++ b/packages/client-generator/src/runtime/cli.ts @@ -45,6 +45,9 @@ export type CliCommand = { */ 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. */ @@ -169,6 +172,21 @@ const GLOBAL_FLAGS: Record json: { key: 'json' }, }; +/** + * The name to print in help: the command the CLI was invoked as. A global install resolves + * `argv[1]` to the bin itself, so its basename is exactly what the user typed. A Windows + * `.cmd` shim, a `node dist/cafe.cli.js`, and a `tsx client.cli.ts` run all pass the script + * path instead — printing that would name a command nobody can type, so a script extension + * and the `.cli` marker come off: `cafe.cli.js` prints `cafe`. + */ +export function invokedName(scriptPath: string | undefined, fallback: string): string { + if (scriptPath === undefined) return fallback; + const base = scriptPath.replace(/^.*[\\/]/, ''); + const withoutExtension = base.replace(/\.(mjs|cjs|js|mts|cts|ts|cmd|bat|ps1|exe)$/i, ''); + const name = withoutExtension.replace(/\.cli$/i, ''); + return name === '' ? fallback : name; +} + /** * The shell-typable form of a group name: an OpenAPI tag can contain spaces ("Some * multi-word tag"), which only resolves if the user quotes it. Commands are addressed by @@ -199,7 +217,8 @@ function callInputs( argsStyle: CliWiring['argsStyle'] ): Record | undefined { const inputs: Record = {}; - if (argsStyle === 'flat') { + // A command the generator marked `grouped` keeps the namespaced shape even here. + if (argsStyle === 'flat' && command.argsStyle !== 'grouped') { Object.assign(inputs, positionals, params); if (body !== undefined) { if (command.body?.merged === true) Object.assign(inputs, body as Record); diff --git a/packages/client-generator/src/runtime/create-client.ts b/packages/client-generator/src/runtime/create-client.ts index d4316fcb87..95d2765ddf 100644 --- a/packages/client-generator/src/runtime/create-client.ts +++ b/packages/client-generator/src/runtime/create-client.ts @@ -156,13 +156,18 @@ function kindFor(op: OperationDescriptor): ParseAs | 'void' { return 'auto'; } -/** The call's inputs in namespaced form, converting first on a flat-style client. */ +/** + * The call's inputs in namespaced form, converting first on a flat-style client. An + * operation the generator marked `argsStyle: 'grouped'` is already namespaced — its names + * could not be merged, so its input type never offered the flat shape. + */ function inputOf( op: OperationDescriptor, args: OperationArgs, config: ClientConfig ): OperationArgs { - return config.argsStyle === 'flat' ? namespaceArgs(op, args) : args; + const merged = config.argsStyle === 'flat' && op.argsStyle !== 'grouped'; + return merged ? namespaceArgs(op, args) : args; } /** Route the namespaced args to the request pieces. */ diff --git a/packages/client-generator/src/runtime/types.ts b/packages/client-generator/src/runtime/types.ts index 85fdfdc157..5421fe17f9 100644 --- a/packages/client-generator/src/runtime/types.ts +++ b/packages/client-generator/src/runtime/types.ts @@ -71,6 +71,12 @@ export type OperationDescriptor = { /** OR-alternatives, each an AND-set: the runtime applies the first fully-configured one. */ security?: readonly (readonly SecuritySpec[])[]; pagination?: PaginationSpec; + /** + * `'grouped'` marks an operation that takes its inputs namespaced by layer even on a + * `argsStyle: 'flat'` client — the generator sets it where a merged call could not carry + * one name for two layers, and the operation's own input type says the same. + */ + argsStyle?: 'grouped'; /** * Declared success-response headers for throw-mode `{ envelope: true }`. * `name` is the lowercased wire name; `key` is the camelCase envelope property. diff --git a/tests/e2e/generate-client/cafe.snapshot.ts b/tests/e2e/generate-client/cafe.snapshot.ts index 40f97ba039..56cdc8edd5 100644 --- a/tests/e2e/generate-client/cafe.snapshot.ts +++ b/tests/e2e/generate-client/cafe.snapshot.ts @@ -875,6 +875,12 @@ export type OperationDescriptor = { /** OR-alternatives, each an AND-set: the runtime applies the first fully-configured one. */ security?: readonly (readonly SecuritySpec[])[]; pagination?: PaginationSpec; + /** + * `'grouped'` marks an operation that takes its inputs namespaced by layer even on a + * `argsStyle: 'flat'` client — the generator sets it where a merged call could not carry + * one name for two layers, and the operation's own input type says the same. + */ + argsStyle?: 'grouped'; /** * Declared success-response headers for throw-mode `{ envelope: true }`. * `name` is the lowercased wire name; `key` is the camelCase envelope property. @@ -1850,13 +1856,18 @@ function kindFor(op: OperationDescriptor): ParseAs | 'void' { return 'auto'; } -/** The call's inputs in namespaced form, converting first on a flat-style client. */ +/** + * The call's inputs in namespaced form, converting first on a flat-style client. An + * operation the generator marked `argsStyle: 'grouped'` is already namespaced — its names + * could not be merged, so its input type never offered the flat shape. + */ function inputOf( op: OperationDescriptor, args: OperationArgs, config: ClientConfig ): OperationArgs { - return config.argsStyle === 'flat' ? namespaceArgs(op, args) : args; + const merged = config.argsStyle === 'flat' && op.argsStyle !== 'grouped'; + return merged ? namespaceArgs(op, args) : args; } /** Route the namespaced args to the request pieces. */ diff --git a/tests/e2e/generate-client/cli-compose.test.ts b/tests/e2e/generate-client/cli-compose.test.ts index 118a787a4b..4adddf3956 100644 --- a/tests/e2e/generate-client/cli-compose.test.ts +++ b/tests/e2e/generate-client/cli-compose.test.ts @@ -195,7 +195,9 @@ describe('config-driven composition (client.cliOutput)', () => { encoding: 'utf-8', }); expect(help.status, help.stderr).toBe(0); - expect(help.stdout).toContain('Usage: cafe.ts '); + // Run as a script, so help names the file without its extension — never `cafe.ts`, + // which is not a command anyone can type. + expect(help.stdout).toContain('Usage: cafe '); expect(help.stdout).toContain('shop'); expect(help.stdout).toContain('kitchen'); }); diff --git a/tests/e2e/generate-client/examples/zero-install-quickstart/src/api/client.ts b/tests/e2e/generate-client/examples/zero-install-quickstart/src/api/client.ts index 272d960b5f..80d9f65886 100644 --- a/tests/e2e/generate-client/examples/zero-install-quickstart/src/api/client.ts +++ b/tests/e2e/generate-client/examples/zero-install-quickstart/src/api/client.ts @@ -211,6 +211,12 @@ export type OperationDescriptor = { /** OR-alternatives, each an AND-set: the runtime applies the first fully-configured one. */ security?: readonly (readonly SecuritySpec[])[]; pagination?: PaginationSpec; + /** + * `'grouped'` marks an operation that takes its inputs namespaced by layer even on a + * `argsStyle: 'flat'` client — the generator sets it where a merged call could not carry + * one name for two layers, and the operation's own input type says the same. + */ + argsStyle?: 'grouped'; /** * Declared success-response headers for throw-mode `{ envelope: true }`. * `name` is the lowercased wire name; `key` is the camelCase envelope property. @@ -1123,13 +1129,18 @@ function kindFor(op: OperationDescriptor): ParseAs | 'void' { return 'auto'; } -/** The call's inputs in namespaced form, converting first on a flat-style client. */ +/** + * The call's inputs in namespaced form, converting first on a flat-style client. An + * operation the generator marked `argsStyle: 'grouped'` is already namespaced — its names + * could not be merged, so its input type never offered the flat shape. + */ function inputOf( op: OperationDescriptor, args: OperationArgs, config: ClientConfig ): OperationArgs { - return config.argsStyle === 'flat' ? namespaceArgs(op, args) : args; + const merged = config.argsStyle === 'flat' && op.argsStyle !== 'grouped'; + return merged ? namespaceArgs(op, args) : args; } /** Route the namespaced args to the request pieces. */ diff --git a/tests/e2e/generate-client/python.test.ts b/tests/e2e/generate-client/python.test.ts index b8ff3c1e84..1005e598a8 100644 --- a/tests/e2e/generate-client/python.test.ts +++ b/tests/e2e/generate-client/python.test.ts @@ -202,3 +202,79 @@ describe('generate-client python generator, parameter names an SDK cannot take l expect(result.status, result.stderr).toBe(0); }); }); + +describe('generate-client python generator, a paginated operation under a path parameter', () => { + let dir: string; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), 'python-page-path-')); + writeFileSync( + join(dir, 'openapi.yaml'), + [ + 'openapi: 3.1.0', + 'info: { title: Nested, version: 1.0.0 }', + 'servers: [{ url: http://127.0.0.1:3141 }]', + 'paths:', + ' /orders/{orderId}/items:', + ' get:', + ' operationId: listOrderItems', + ' x-redoclyPagination:', + ' { style: cursor, cursorParam: cursor, nextCursor: /next, items: /items }', + ' parameters:', + ' - { name: orderId, in: path, required: true, schema: { type: string } }', + ' - { name: cursor, in: query, required: false, schema: { type: string } }', + ' responses:', + " '200':", + ' description: ok', + ' content:', + ' application/json:', + ' schema:', + ' type: object', + ' properties:', + ' items: { type: array, items: { type: object } }', + ' next: { type: string }', + '', + ].join('\n'), + 'utf-8' + ); + generate(join(dir, 'openapi.yaml'), join(dir, 'client.ts'), ['--generator', 'python']); + }); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it.skipIf(!hasHttpx)('substitutes the path value into every page request', () => { + // The iterator serves its own pages here: what matters is the URL it asks for, which + // used to be the template itself (`/orders/{orderId}/items`) with the value dropped. + const script = [ + 'import json, sys, threading', + 'from http.server import BaseHTTPRequestHandler, HTTPServer', + 'seen = []', + 'class Handler(BaseHTTPRequestHandler):', + ' def do_GET(self):', + ' seen.append(self.path)', + ' first = "cursor=" not in self.path', + ' body = {"items": [{"id": "i1"}], "next": "c2" if first else None}', + ' payload = json.dumps(body).encode()', + ' self.send_response(200)', + ' self.send_header("content-type", "application/json")', + ' self.send_header("content-length", str(len(payload)))', + ' self.end_headers()', + ' self.wfile.write(payload)', + ' def log_message(self, *args):', + ' pass', + 'server = HTTPServer(("127.0.0.1", 3141), Handler)', + 'threading.Thread(target=server.serve_forever, daemon=True).start()', + `sys.path.insert(0, ${JSON.stringify(dir)})`, + 'import client', + 'pages = list(client.Client().list_order_items_pages("ord_7"))', + 'assert len(pages) == 2, pages', + 'assert seen == ["/orders/ord_7/items", "/orders/ord_7/items?cursor=c2"], seen', + 'print("PYTHON_PAGE_PATH_OK")', + ].join('\n'); + const result = spawnSync('python3', ['-c', script], { encoding: 'utf-8' }); + expect(result.status, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`).toBe(0); + expect(result.stdout).toContain('PYTHON_PAGE_PATH_OK'); + }); +}); From 6cf104ad46bfd839a092992ee248c8338a0f2380 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Wed, 19 Aug 2026 19:28:06 +0300 Subject: [PATCH 208/211] fix(client-generator): export invokedName, and stop reading an allOf refinement as a collision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A package-mode CLI imported `invokedName` from the package root, which exported only `runCli` — so every `--runtime package` CLI failed at import. The root exports it now, and a guard test reads the value names out of the emitted import line and checks each against the root's exports, so the next name added there cannot drift. `allOf` members routinely redeclare a property to refine it, and counting the name once per member read as two layers using one key: a mergeable operation fell back to the namespaced shape. The body's property names are deduplicated, because the merged body carries one key per name either way. --- .../src/emitters/__tests__/cli.test.ts | 26 ++++++++++++++++ .../src/emitters/__tests__/operations.test.ts | 30 +++++++++++++++++++ .../src/emitters/render-client.ts | 9 ++++-- packages/client-generator/src/index.ts | 2 +- 4 files changed, 63 insertions(+), 4 deletions(-) diff --git a/packages/client-generator/src/emitters/__tests__/cli.test.ts b/packages/client-generator/src/emitters/__tests__/cli.test.ts index 381e6efe2e..2c390ba08d 100644 --- a/packages/client-generator/src/emitters/__tests__/cli.test.ts +++ b/packages/client-generator/src/emitters/__tests__/cli.test.ts @@ -286,6 +286,32 @@ describe('renderCliModule', () => { }); }); +describe('the package-mode import line', () => { + it('names only values the package root exports', async () => { + // The emitted entry is the only consumer of these names, and a missing export breaks + // every package-mode CLI at import time rather than at generation. + const out = renderCliModule(MODEL, { + stem: 'client', + importExt: 'js', + runtime: 'package', + zodSelected: false, + }); + const line = out + .split('\n') + .find((candidate) => candidate.includes('from "@redocly/client-generator"')); + expect(line, 'no package import line found').toBeDefined(); + const names = line! + .slice(line!.indexOf('{') + 1, line!.indexOf('}')) + .split(',') + .map((specifier) => specifier.trim()) + .filter((specifier) => specifier !== '' && !specifier.startsWith('type ')); + const root = (await import('../../index.js')) as Record; + for (const name of names) { + expect(typeof root[name], `${name} is imported but not exported`).toBe('function'); + } + }); +}); + describe('renderComposedCliEntry', () => { it('keeps import bindings legal for digit-leading aliases and unique for colliding ones', () => { const out = renderComposedCliEntry( diff --git a/packages/client-generator/src/emitters/__tests__/operations.test.ts b/packages/client-generator/src/emitters/__tests__/operations.test.ts index 2a92dce620..6dd560bad8 100644 --- a/packages/client-generator/src/emitters/__tests__/operations.test.ts +++ b/packages/client-generator/src/emitters/__tests__/operations.test.ts @@ -232,6 +232,36 @@ describe('call inputs — the merged shape (argsStyle: flat)', () => { expect(out).toContain('argsStyle: "grouped"'); }); + it('a property two allOf members declare is one key, not a collision', () => { + const out = emitFlat({ + name: 'saveThing', + path: '/things/{id}', + pathParams: [param('id', 'path', true)], + requestBody: { + contentType: 'application/json', + required: true, + schema: { + kind: 'intersection', + members: [ + { + kind: 'object', + properties: [ + { name: 'label', schema: SCALAR, required: true }, + { name: 'note', schema: SCALAR, required: false }, + ], + }, + // A refinement of the same property — the merged body still has one `label`. + { kind: 'object', properties: [{ name: 'label', schema: SCALAR, required: false }] }, + ], + }, + }, + }); + expect(out).toContain( + 'export type SaveThingVariables = {\n id: string;\n} & SaveThingBody;' + ); + expect(out).not.toContain('argsStyle: "grouped"'); + }); + it('falls back to the namespaced shape when one name lands in two layers', () => { const out = emitFlat({ name: 'getThing', diff --git a/packages/client-generator/src/emitters/render-client.ts b/packages/client-generator/src/emitters/render-client.ts index 00ab2884e8..aeddc854c6 100644 --- a/packages/client-generator/src/emitters/render-client.ts +++ b/packages/client-generator/src/emitters/render-client.ts @@ -149,13 +149,16 @@ function mergedBodyProperties( const resolved = resolvedSchema(schema, schemas); if (resolved?.kind === 'object') return resolved.properties.map((property) => property.name); if (resolved?.kind !== 'intersection') return undefined; - const names: string[] = []; + // Deduplicated: `allOf` members routinely redeclare a property to refine it, and the + // merged body still carries one key for it — counting it twice would read as a collision + // and push a mergeable operation back to the namespaced shape. + const names = new Set(); for (const member of resolved.members) { const memberNames = mergedBodyProperties(member, schemas); if (memberNames === undefined) return undefined; - names.push(...memberNames); + for (const name of memberNames) names.add(name); } - return names; + return [...names]; } /** diff --git a/packages/client-generator/src/index.ts b/packages/client-generator/src/index.ts index 578597d861..caf6f35d65 100644 --- a/packages/client-generator/src/index.ts +++ b/packages/client-generator/src/index.ts @@ -51,7 +51,7 @@ export type { TokenProvider, } from './runtime/index.js'; // The generated-CLI engine (package-mode cli files import it from the package root). -export { runCli } from './runtime/cli.js'; +export { invokedName, runCli } from './runtime/cli.js'; export type { CliAuthScheme, CliCommand, From 147e81b197392333c0f43d205f94047db0be4a5f Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Thu, 20 Aug 2026 13:34:12 +0300 Subject: [PATCH 209/211] fix(cli): read the eject assets from the package that owns them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `eject-generator` resolved its assets beside its own module, which only the CLI build produces (`build.mjs` copies them into `lib/`). Running from source — what `npm run cli` does — failed with an ENOENT naming a path inside `src` that nothing creates, so a contributor could not eject a generator during development. Every generator failed, not one: the directory was missing entirely. The published CLI bundles everything and has no `node_modules`, so the copy beside the bundle has to stay. When it is absent, the assets now come from `@redocly/client-generator`, which is resolvable from a checkout and from any normal install. A checkout whose generator bundles were never built gets a message naming the command that builds them, rather than a stack trace. `eject.test.ts` only ever ran the bundle, which is why this reached review. It now also ejects through `packages/cli/src/index.ts`, and that case fails with the reported ENOENT if the resolution is reverted. --- packages/cli/src/commands/eject-generator.ts | 19 +++++++++++++--- tests/e2e/generate-client/eject.test.ts | 24 +++++++++++++++++++- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts index a68ed87bc0..a47fd1082e 100644 --- a/packages/cli/src/commands/eject-generator.ts +++ b/packages/cli/src/commands/eject-generator.ts @@ -10,6 +10,7 @@ import { rmSync, writeFileSync, } from 'node:fs'; +import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -60,11 +61,23 @@ const AGENTS_BEGIN = const AGENTS_END = ''; /** - * The assets directory beside the bundled module — the CLI build copies it into `lib/`. - * Absent when running straight from `src`; build the CLI first. + * Where the shipped generator sources and skills live. The published CLI bundles everything + * and has no `node_modules`, so its build copies the assets beside the bundle; running from + * `src` there is no such copy, and the assets are read from the package that owns them. */ export function ejectAssetsDir(): string { - return fileURLToPath(new URL('./eject-assets/', import.meta.url)); + const bundled = fileURLToPath(new URL('./eject-assets/', import.meta.url)); + if (existsSync(join(bundled, 'generators'))) return bundled; + // `resolve` lands on the toolkit's entry module; the assets sit at its package root. + const entry = createRequire(import.meta.url).resolve(TOOLKIT_PACKAGE); + const owned = join(dirname(entry), '..', 'eject-assets'); + if (existsSync(join(owned, 'generators'))) return owned; + // Only reachable in a checkout whose generator bundles have never been built. Saying so + // beats an ENOENT stack trace naming a path the reader has no reason to expect. + throw new HandledError( + `\n❌ The ejectable generator sources are missing from ${TOOLKIT_PACKAGE}.\n` + + ` In a checkout of the CLI, build them first: npm run prepare -w ${TOOLKIT_PACKAGE}\n` + ); } /** Copy a shipped skill into the repo's `.claude/skills//SKILL.md`, overwriting ours. */ diff --git a/tests/e2e/generate-client/eject.test.ts b/tests/e2e/generate-client/eject.test.ts index ae1abfd2b7..675cae98ae 100644 --- a/tests/e2e/generate-client/eject.test.ts +++ b/tests/e2e/generate-client/eject.test.ts @@ -14,7 +14,7 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { cliEntry, repoRoot } from './helpers.js'; +import { cliEntry, repoRoot, tsxBin } from './helpers.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -219,3 +219,25 @@ describe('eject-generator (end-to-end)', () => { expect(readFileSync(ejected, 'utf-8')).toContain('<<<<<<<'); }, 60_000); }); + +describe('eject-generator from source (no bundle)', () => { + // The command reads its assets beside the bundle, which only the CLI build produces. + // Running `packages/cli/src` — what `npm run cli` does — used to fail with an ENOENT + // naming a path inside `src`, so contributors could not eject during development. + it('ejects every generator when the CLI runs from src', () => { + const project = makeProject(); + try { + for (const generator of ['python', 'go', 'php', 'typescript', 'cli']) { + const result = spawnSync( + tsxBin, + [join(repoRoot, 'packages/cli/src/index.ts'), 'eject-generator', generator], + { cwd: project, encoding: 'utf-8' } + ); + expect(result.status, `${generator}: ${result.stdout}\n${result.stderr}`).toBe(0); + expect(existsSync(join(project, `generators/${generator}.mjs`))).toBe(true); + } + } finally { + rmSync(project, { recursive: true, force: true }); + } + }, 120_000); +}); From cae28e8a07d30aa1d0d24f601f54df6e8b420583 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Thu, 20 Aug 2026 13:57:36 +0300 Subject: [PATCH 210/211] feat(cli): tell the reader how to run the generator it just ejected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The eject message ended at the skills it wrote, so the next step was left to the reader. Running bare `redocly generate-client` afterwards fails with "No API to generate", because the eject wires the generator but not an output — the reader had to go find that out. It now ends with the command: `redocly generate-client --output `, plus `--generator ` when the config was not wired and nothing else points at the copy, a line saying to edit the file and run it again, and a link to the command reference. The docs page gained the same as a section. The test asserts both forms and then runs the wired one, so the printed instruction cannot drift from a command that works. --- docs/@v2/commands/eject-generator.md | 18 ++++++++++++ packages/cli/src/commands/eject-generator.ts | 9 +++++- tests/e2e/generate-client/eject.test.ts | 30 ++++++++++++++++++-- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/docs/@v2/commands/eject-generator.md b/docs/@v2/commands/eject-generator.md index 454fdaa316..4596419700 100644 --- a/docs/@v2/commands/eject-generator.md +++ b/docs/@v2/commands/eject-generator.md @@ -77,6 +77,24 @@ client: If you leave the ejected generator unmodified, its output is byte-identical to the output of the built-in generator. To roll back, delete the file and the config line. +## Run the ejected generator + +Generation is the same command as before the eject, because the config now points at your copy: + +```sh +redocly generate-client openapi.yaml --output src/client.ts +``` + +If you did not wire the config, name the file with `--generator`: + +```sh +redocly generate-client openapi.yaml --output src/client.ts --generator ./generators/python.mjs +``` + +The command reports a generator that takes over a built-in name, so you can see that your copy is the one that runs. +Edit the file and run the command again to see the change. +The eject command prints these instructions as well. + ## Update an ejected generator The `redocly eject-generator --update` command merges a newer version into your copy. diff --git a/packages/cli/src/commands/eject-generator.ts b/packages/cli/src/commands/eject-generator.ts index a47fd1082e..f2fa3d9b8d 100644 --- a/packages/cli/src/commands/eject-generator.ts +++ b/packages/cli/src/commands/eject-generator.ts @@ -54,6 +54,7 @@ export const FRAMEWORK_VARIANTS = new Map([ /** The packages an ejected generator imports; recorded as devDependencies. */ const TOOLKIT_PACKAGE = '@redocly/client-generator'; +const DOCS_URL = 'https://redocly.com/docs/cli/commands/eject-generator'; const CORE_PACKAGE = '@redocly/openapi-core'; const AGENTS_BEGIN = @@ -584,7 +585,13 @@ export const handleEjectGenerator = async ({ ? `Added it to client.generators in ${relative(process.cwd(), config.configPath!)} — the path to your copy replaces the built-in name.\n` : `Point your config at the file — the path to your copy replaces the built-in name:\n\n` + ` client:\n generators:\n - ${configEntry}\n\n`) + - `Your agent's skills: ${designSkill} (this generator's design) and ${authoringSkill} (the toolkit).\n` + `Your agent's skills: ${designSkill} (this generator's design) and ${authoringSkill} (the toolkit).\n` + + // The next command, spelled out: a wired config still needs an output, and an unwired + // copy is reached with `--generator`. Either way the reader can run it without + // leaving the terminal to look it up. + `\nRun it: redocly generate-client --output ${wired ? '' : ` --generator ${configEntry}`}\n` + + `Edit ${printedTarget} and run that again to see your change.\n` + + `Reference: ${DOCS_URL}\n` ); // Last, so wiring the dependency or the config entry failing is not reported as success. ejectGeneratorTelemetry.eject_generator_outcome = 'success'; diff --git a/tests/e2e/generate-client/eject.test.ts b/tests/e2e/generate-client/eject.test.ts index 675cae98ae..7485d6c37e 100644 --- a/tests/e2e/generate-client/eject.test.ts +++ b/tests/e2e/generate-client/eject.test.ts @@ -118,13 +118,39 @@ describe('eject-generator (end-to-end)', () => { try { const eject = run(manual, ['eject-generator', 'go']); expect(eject.status, eject.stderr).toBe(0); - expect(eject.stderr + eject.stdout).toContain('generators:'); - expect(eject.stderr + eject.stdout).toContain('./generators/go.mjs'); + const output = eject.stderr + eject.stdout; + expect(output).toContain('generators:'); + expect(output).toContain('./generators/go.mjs'); + // Unwired, the run instruction has to name the copy — nothing else points at it. + expect(output).toContain( + 'Run it: redocly generate-client --output --generator ./generators/go.mjs' + ); + expect(output).toContain('https://redocly.com/docs/cli/commands/eject-generator'); } finally { rmSync(manual, { recursive: true, force: true }); } }, 60_000); + it('tells the reader how to run what it just ejected', () => { + const project = makeProject(); + try { + writeFileSync(join(project, 'redocly.yaml'), 'apis:\n main:\n root: openapi.yaml\n'); + const eject = run(project, ['eject-generator', 'python']); + expect(eject.status, eject.stderr).toBe(0); + const output = eject.stderr + eject.stdout; + // Wired into the config, the generator needs no flag — only an api and an output. + expect(output).toContain('Run it: redocly generate-client --output \n'); + expect(output).toContain('Edit generators/python.mjs and run that again'); + expect(output).toContain('https://redocly.com/docs/cli/commands/eject-generator'); + // And that command works as printed. + const generated = run(project, ['generate-client', 'openapi.yaml', '--output', 'client.ts']); + expect(generated.status, generated.stderr).toBe(0); + expect(existsSync(join(project, 'client.py'))).toBe(true); + } finally { + rmSync(project, { recursive: true, force: true }); + } + }, 60_000); + it('THE headline: an ejected-unmodified generator produces byte-identical output', () => { const builtin = run(project, [ 'generate-client', From 44c511353bd8a95e8062798636cf395543c63b55 Mon Sep 17 00:00:00 2001 From: Roman Marshevskyi Date: Thu, 20 Aug 2026 16:57:48 +0300 Subject: [PATCH 211/211] test(cli): co-locate this branch's unit tests, and write the convention down MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.claude/rules/testing.md` now says where a unit test goes: a `__tests__` folder beside the file it tests, mirroring its name, rather than a second source tree inside `src/__tests__/`. The older tests that do the latter are historical; a reviewer should not have to work out which layout a new test follows. This branch's three files move accordingly. Two of them tested the same module from two different places — `eject-generator.test.ts` existed twice — so they are one file now, with the `npm pack` timeout scoped to the test that needs it instead of the whole file. The rule says that too: one module, one test file. Moving them caught a test asserting `unexpected-error` *because* eject could not read its assets from source. That path is supported now, so it records `missing-target` instead; the test triggers a genuinely uncategorized failure — a destination that is a file — and says why the old trigger no longer qualifies. --- .claude/rules/testing.md | 6 ++ .../cli/src/__tests__/eject-generator.test.ts | 42 ---------- .../__tests__}/eject-generator.test.ts | 78 +++++++++++++++---- .../client-generator-telemetry.test.ts | 8 +- 4 files changed, 73 insertions(+), 61 deletions(-) delete mode 100644 packages/cli/src/__tests__/eject-generator.test.ts rename packages/cli/src/{__tests__/commands => commands/__tests__}/eject-generator.test.ts (73%) rename packages/cli/src/{ => utils}/__tests__/client-generator-telemetry.test.ts (94%) diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 31fd4feeb4..21ad5eee8f 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -2,6 +2,12 @@ 1. Write meaningful tests that exercise real behavior — not tests that exist only to raise coverage. One focused, clear test is enough. +1. A unit test lives in a `__tests__` folder beside the file it tests, and mirrors its name: + `src/commands/eject-generator.ts` is tested by `src/commands/__tests__/eject-generator.test.ts`. + Do not rebuild the source tree inside a `__tests__` folder (`src/__tests__/commands/…`) — the + older tests that do are historical, and a reviewer should not have to guess which layout a + new test follows. One module gets one test file: split a long one by `describe`, not by adding + a second file for the same source. 1. Rule tests are unit tests by convention: parse a YAML document, run `lintDocument`, and assert with `toMatchInlineSnapshot` — a behavior test in itself (given this input, these problems). Generate new snapshots and update stale ones as part of the change. diff --git a/packages/cli/src/__tests__/eject-generator.test.ts b/packages/cli/src/__tests__/eject-generator.test.ts deleted file mode 100644 index c0d32f7e74..0000000000 --- a/packages/cli/src/__tests__/eject-generator.test.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { readFileSync } from 'node:fs'; -import { dirname, join, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -import { packedAssets } from '../commands/eject-generator.js'; - -const clientGeneratorDir = resolve( - dirname(fileURLToPath(import.meta.url)), - '../../../client-generator' -); - -// `npm pack` on a directory runs that package's prepare script, so give it room. -vi.setConfig({ testTimeout: 180_000 }); - -describe('packedAssets', () => { - it('reads the generator and its skills out of a packed @redocly/client-generator', () => { - // A directory stands in for the version spec `--update` passes: same pack, same - // extraction, no registry needed to prove the mechanism. - const members = [ - 'package/eject-assets/generators/php.mjs', - 'package/eject-assets/skills/php-generator/SKILL.md', - 'package/eject-assets/skills/not-a-member/SKILL.md', - ]; - const assets = packedAssets(clientGeneratorDir, members); - expect(assets.get(members[0])).toBe( - readFileSync(join(clientGeneratorDir, 'eject-assets/generators/php.mjs'), 'utf-8') - ); - expect(assets.get(members[1])).toBe( - readFileSync(join(clientGeneratorDir, 'eject-assets/skills/php-generator/SKILL.md'), 'utf-8') - ); - // A member the packed version does not ship is absent, so the caller falls back per file. - expect(assets.has(members[2])).toBe(false); - }); - - it('returns nothing when the spec cannot be packed, so the caller can fall back', () => { - expect( - packedAssets('@redocly/client-generator@0.0.0-does-not-exist', [ - 'package/eject-assets/generators/php.mjs', - ]).size - ).toBe(0); - }); -}); diff --git a/packages/cli/src/__tests__/commands/eject-generator.test.ts b/packages/cli/src/commands/__tests__/eject-generator.test.ts similarity index 73% rename from packages/cli/src/__tests__/commands/eject-generator.test.ts rename to packages/cli/src/commands/__tests__/eject-generator.test.ts index 277e3e2360..e2dcdeb1ab 100644 --- a/packages/cli/src/__tests__/commands/eject-generator.test.ts +++ b/packages/cli/src/commands/__tests__/eject-generator.test.ts @@ -1,11 +1,17 @@ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { outdent } from 'outdent'; -import { handleEjectGenerator, threeWayMerge, wireConfig } from '../../commands/eject-generator.js'; import { ejectGeneratorTelemetry } from '../../utils/client-generator-telemetry.js'; import type { CommandArgs } from '../../wrapper.js'; +import { + handleEjectGenerator, + packedAssets, + threeWayMerge, + wireConfig, +} from '../eject-generator.js'; const baseArgs = { version: '0.0.0', config: undefined } as unknown as Omit< CommandArgs>, @@ -226,19 +232,26 @@ describe('eject telemetry (coarse categories only)', () => { }); it('a failure we did not account for still records an outcome', async () => { - // The shipped assets sit next to the BUILT module, so reading one from source fails - // the same way a broken install would — an error no branch sets an outcome for. - await expect( - handleEjectGenerator({ - ...baseArgs, - argv: { generator: 'php', update: true }, - } as CommandArgs) - ).rejects.toThrow(); - expect(ejectGeneratorTelemetry).toEqual({ - eject_generator_action: 'update', - eject_generator_name: 'php', - eject_generator_outcome: 'unexpected-error', - }); + // A destination that is a FILE: writing into it throws ENOTDIR, which no branch + // categorizes. (Reading the assets from source used to be the trigger here; it is a + // supported path now that they resolve from the package that owns them.) + const blocked = mkdtempSync(join(tmpdir(), 'eject-blocked-')); + writeFileSync(join(blocked, 'generators'), 'not a directory', 'utf-8'); + try { + await expect( + handleEjectGenerator({ + ...baseArgs, + argv: { generator: 'php', dir: join(blocked, 'generators') }, + } as CommandArgs) + ).rejects.toThrow(); + expect(ejectGeneratorTelemetry).toEqual({ + eject_generator_action: 'eject', + eject_generator_name: 'php', + eject_generator_outcome: 'unexpected-error', + }); + } finally { + rmSync(blocked, { recursive: true, force: true }); + } }); it('an unknown generator records the outcome but never the user-supplied name', async () => { @@ -252,3 +265,38 @@ describe('eject telemetry (coarse categories only)', () => { expect(ejectGeneratorTelemetry.eject_generator_name).toBeUndefined(); }); }); + +const clientGeneratorDir = resolve( + dirname(fileURLToPath(import.meta.url)), + '../../../../client-generator' +); + +describe('packedAssets', () => { + it('reads the generator and its skills out of a packed @redocly/client-generator', () => { + // A directory stands in for the version spec `--update` passes: same pack, same + // extraction, no registry needed to prove the mechanism. + const members = [ + 'package/eject-assets/generators/php.mjs', + 'package/eject-assets/skills/php-generator/SKILL.md', + 'package/eject-assets/skills/not-a-member/SKILL.md', + ]; + const assets = packedAssets(clientGeneratorDir, members); + expect(assets.get(members[0])).toBe( + readFileSync(join(clientGeneratorDir, 'eject-assets/generators/php.mjs'), 'utf-8') + ); + expect(assets.get(members[1])).toBe( + readFileSync(join(clientGeneratorDir, 'eject-assets/skills/php-generator/SKILL.md'), 'utf-8') + ); + // A member the packed version does not ship is absent, so the caller falls back per file. + expect(assets.has(members[2])).toBe(false); + // `npm pack` on a directory runs that package's prepare script, so give it room. + }, 180_000); + + it('returns nothing when the spec cannot be packed, so the caller can fall back', () => { + expect( + packedAssets('@redocly/client-generator@0.0.0-does-not-exist', [ + 'package/eject-assets/generators/php.mjs', + ]).size + ).toBe(0); + }); +}); diff --git a/packages/cli/src/__tests__/client-generator-telemetry.test.ts b/packages/cli/src/utils/__tests__/client-generator-telemetry.test.ts similarity index 94% rename from packages/cli/src/__tests__/client-generator-telemetry.test.ts rename to packages/cli/src/utils/__tests__/client-generator-telemetry.test.ts index 4acd5f9b6a..07342351ac 100644 --- a/packages/cli/src/__tests__/client-generator-telemetry.test.ts +++ b/packages/cli/src/utils/__tests__/client-generator-telemetry.test.ts @@ -2,16 +2,16 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { BUILTIN_META } from '../../../client-generator/src/generators/meta.js'; -import { EJECTABLE, FRAMEWORK_VARIANTS } from '../commands/eject-generator.js'; -import { collectGeneratorUsage } from '../commands/generate-client.js'; +import { BUILTIN_META } from '../../../../client-generator/src/generators/meta.js'; +import { EJECTABLE, FRAMEWORK_VARIANTS } from '../../commands/eject-generator.js'; +import { collectGeneratorUsage } from '../../commands/generate-client.js'; import { BUILTIN_GENERATOR_NAMES, categorizeGenerateClientError, collectToolkitImports, generateClientTelemetry, parseEjectedProvenance, -} from '../utils/client-generator-telemetry.js'; +} from '../client-generator-telemetry.js'; describe('collectToolkitImports', () => { it('returns only OUR helper names from client-generator imports — never user identifiers', () => {