diff --git a/README.md b/README.md index b60369c..de88cb2 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,6 @@ const myEmitter: Emitter = { /* ... */ ], generateErrors: () => [], - generateConfig: () => [], generateTests: () => [], fileHeader: () => "// Auto-generated by oagen. Do not edit.", }; diff --git a/docs/agents/architecture.md b/docs/agents/architecture.md index 876d835..5f44c33 100644 --- a/docs/agents/architecture.md +++ b/docs/agents/architecture.md @@ -10,7 +10,7 @@ Three stages transform an OpenAPI spec into SDK files: 1. **Parse** (`src/parser/parse.ts`) — Load and bundle the spec via `@redocly/openapi-core`, extract `Model[]`, `Enum[]`, and `Service[]` into the IR (`ApiSpec`). Sub-modules handle ref resolution (`refs.ts`), schema walking (`schemas.ts`), operation grouping (`operations.ts`), pagination detection (`pagination.ts`), and inline model extraction (`inline-models.ts`). -2. **Emit** (`src/engine/orchestrator.ts`) — Call each emitter method in order (`generateModels` → `generateEnums` → `generateResources` → `generateClient` → `generateErrors` → `generateConfig` → `generateTypeSignatures` → `generateTests` → `generateManifest`), collect `GeneratedFile[]`, prepend file headers. +2. **Emit** (`src/engine/orchestrator.ts`) — Call each emitter method in order (`generateModels` → `generateEnums` → `generateResources` → `generateClient` → `generateErrors` → `generateTypeSignatures` → `generateTests` → `generateManifest`), collect `GeneratedFile[]`, prepend file headers. 3. **Write** (`src/engine/writer.ts`) — Write files to disk. New files are created in full. Existing files are merged at the AST level via `merger.ts` (additive-only — new symbols appended, existing symbols untouched). Files marked `skipIfExists` are never overwritten. diff --git a/docs/architecture/emitter-contract.md b/docs/architecture/emitter-contract.md index 36e0ce7..871211e 100644 --- a/docs/architecture/emitter-contract.md +++ b/docs/architecture/emitter-contract.md @@ -17,7 +17,6 @@ interface Emitter { generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[]; generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[]; generateErrors(ctx: EmitterContext): GeneratedFile[]; - generateConfig(ctx: EmitterContext): GeneratedFile[]; generateTypeSignatures?(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[]; generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[]; generateManifest?(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[]; @@ -100,7 +99,7 @@ The `httpKeyByMethod` reverse map is only populated when a manifest (`smoke-mani 5. **Inapplicable methods return `[]`** — If a language doesn't need type signature files, `generateTypeSignatures` returns `[]`. Optional methods like `generateManifest` can simply be omitted. -6. **Composable generators** — Interface methods can compose multiple internal generators. Example: Node's `generateConfig` returns config files plus shared utility files. +6. **Composable generators** — Interface methods can compose multiple internal generators when a language needs them. ## New IR Variants for Emitters diff --git a/docs/architecture/pipeline.md b/docs/architecture/pipeline.md index 01e7a90..1d6acf8 100644 --- a/docs/architecture/pipeline.md +++ b/docs/architecture/pipeline.md @@ -61,7 +61,6 @@ generateEnums(enums, ctx); // Enum types generateResources(services, ctx); // API resource classes generateClient(spec, ctx); // HTTP client generateErrors(ctx); // Error hierarchy -generateConfig(ctx); // Configuration generateTypeSignatures(spec, ctx); // Type annotations (optional) generateTests(spec, ctx); // Tests + fixtures generateManifest?.(spec, ctx); // Smoke-test manifest (optional) diff --git a/docs/core/minimal-emitter.md b/docs/core/minimal-emitter.md index 7901812..6acc44a 100644 --- a/docs/core/minimal-emitter.md +++ b/docs/core/minimal-emitter.md @@ -71,10 +71,6 @@ export const demoEmitter: Emitter = { return []; }, - generateConfig(): GeneratedFile[] { - return []; - }, - generateTypeSignatures(): GeneratedFile[] { return []; }, diff --git a/docs/core/quickstart.md b/docs/core/quickstart.md index 333b7a2..e0d215a 100644 --- a/docs/core/quickstart.md +++ b/docs/core/quickstart.md @@ -105,7 +105,6 @@ export const demoEmitter: Emitter = { }, ], generateErrors: () => [], - generateConfig: () => [], generateTypeSignatures: () => [], generateTests: () => [], fileHeader: () => "# Generated by oagen", diff --git a/examples/reference-emitter/README.md b/examples/reference-emitter/README.md index cbf205f..1d40f0e 100644 --- a/examples/reference-emitter/README.md +++ b/examples/reference-emitter/README.md @@ -9,7 +9,6 @@ A minimal but working TypeScript emitter for oagen, demonstrating the Emitter in - `generateResources` — resource classes using `planOperation` - `generateClient` — top-level client with resource accessors - `generateErrors` — error class hierarchy -- `generateConfig` — config types and base resource class - Type mapping via `mapTypeRef` ## What it does NOT cover diff --git a/examples/reference-emitter/src/emitter.ts b/examples/reference-emitter/src/emitter.ts index 7bba5de..5d60b97 100644 --- a/examples/reference-emitter/src/emitter.ts +++ b/examples/reference-emitter/src/emitter.ts @@ -4,7 +4,6 @@ import { generateEnums } from './generators/enums.js'; import { generateResources } from './generators/resources.js'; import { generateClient } from './generators/client.js'; import { generateErrors } from './generators/errors.js'; -import { generateConfig } from './generators/config.js'; import { generateTests } from './generators/tests.js'; /** @@ -38,10 +37,6 @@ export const typescriptEmitter: Emitter = { return generateErrors(ctx); }, - generateConfig(ctx: EmitterContext): GeneratedFile[] { - return generateConfig(ctx); - }, - generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] { return generateTests(spec, ctx); }, diff --git a/examples/reference-emitter/src/generators/config.ts b/examples/reference-emitter/src/generators/config.ts deleted file mode 100644 index de32043..0000000 --- a/examples/reference-emitter/src/generators/config.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { GeneratedFile, EmitterContext } from '@workos/oagen'; - -export function generateConfig(_ctx: EmitterContext): GeneratedFile[] { - const content = `export interface ClientConfig { - apiKey: string; - baseUrl?: string; -} - -export abstract class BaseResource { - protected readonly config: ClientConfig; - - constructor(config: ClientConfig) { - this.config = config; - } -} -`; - - return [{ path: 'config.ts', content }]; -} diff --git a/src/cli/templates/init.ts b/src/cli/templates/init.ts index 6616736..d865e1b 100644 --- a/src/cli/templates/init.ts +++ b/src/cli/templates/init.ts @@ -144,10 +144,6 @@ export const ${varName}: Emitter = { return []; }, - generateConfig(_ctx: EmitterContext): GeneratedFile[] { - return []; - }, - generateTypeSignatures(_spec: ApiSpec, _ctx: EmitterContext): GeneratedFile[] { return []; }, diff --git a/src/engine/generate-files.ts b/src/engine/generate-files.ts index 1f1904c..918481b 100644 --- a/src/engine/generate-files.ts +++ b/src/engine/generate-files.ts @@ -119,7 +119,6 @@ export function generateAllFiles(spec: ApiSpec, emitter: Emitter, ctx: EmitterCo ...emitter.generateResources(spec.services, ctx), ...emitter.generateClient(spec, ctx), ...emitter.generateErrors(ctx), - ...emitter.generateConfig(ctx), ...(emitter.generateTypeSignatures?.(reachableSpec, ctx) ?? []), ...emitter.generateTests(reachableSpec, ctx), ...(emitter.generateManifest?.(spec, ctx) ?? []), diff --git a/src/engine/merger.ts b/src/engine/merger.ts index 3fbfbcf..8eccf89 100644 --- a/src/engine/merger.ts +++ b/src/engine/merger.ts @@ -18,6 +18,45 @@ const parserCache = new Map(); import { safeParse } from '../utils/tree-sitter.js'; +// --- @deprecated preservation --- + +/** + * When replacing an existing docstring with a generated one, carry over any + * `@deprecated` lines from the existing doc that aren't present in the + * generated doc. This prevents hand-written deprecation notices from being + * silently dropped during docstring refresh. + */ +function preserveDeprecatedTags(existingDoc: string, generatedDoc: string): string { + // Check if the existing docstring contains @deprecated anywhere + if (!existingDoc.includes('@deprecated')) return generatedDoc; + + // If generated doc already has @deprecated, no need to merge + if (generatedDoc.includes('@deprecated')) return generatedDoc; + + // Extract the @deprecated content from the existing doc. + // Handle both multi-line (`* @deprecated ...`) and one-liner (`/** @deprecated ... */`) formats. + const deprecatedParts: string[] = []; + for (const line of existingDoc.split('\n')) { + if (/@deprecated\b/.test(line)) { + // Normalize to JSDoc member format: ` * @deprecated ...` + const match = line.match(/@deprecated\b.*/); + if (match) { + // Strip trailing */ and whitespace (from one-liner JSDoc like `/** @deprecated ... */`) + const cleaned = match[0].replace(/\s*\*\/\s*$/, ''); + deprecatedParts.push(` * ${cleaned}`); + } + } + } + if (deprecatedParts.length === 0) return generatedDoc; + + // Insert @deprecated lines before the closing */ of the generated doc + const closingIdx = generatedDoc.lastIndexOf('*/'); + if (closingIdx === -1) return generatedDoc; + const before = generatedDoc.slice(0, closingIdx); + const after = generatedDoc.slice(closingIdx); + return before + deprecatedParts.join('\n') + '\n ' + after; +} + // --- @oagen-ignore region helpers --- interface IgnoredRegion { @@ -257,12 +296,27 @@ export async function mergeIntoExisting( lastImportEndIndex = linesBefore + stmtLines - 1; } + // Track all names exported by existing statements (both direct exports and + // re-export aliases like `export { X as Y }`). Used to prevent the merger + // from appending a re-export that introduces a duplicate exported name. + const existingExportedNames = new Set(); + for (const stmt of existingStatements.statements) { if (stmt.kind === 'reexport') { existingReexports.add(adapter.normalizeReexport ? adapter.normalizeReexport(stmt.text.trim()) : stmt.text.trim()); + // Collect aliased export names from re-exports + const braceMatch = stmt.text.match(/\{([^}]+)\}/); + if (braceMatch) { + for (const part of braceMatch[1].split(',')) { + const segments = part.trim().split(/\s+as\s+/); + const exportedName = (segments[1] ?? segments[0]).trim(); + if (exportedName) existingExportedNames.add(exportedName); + } + } } if (stmt.key) { existingKeys.add(stmt.key); + existingExportedNames.add(stmt.key); } else { existingUnnamedTexts.add(stmt.text.trim()); } @@ -354,6 +408,24 @@ export async function mergeIntoExisting( preserved++; continue; } + // Also skip re-exports whose aliased names are already defined as + // top-level exports in the existing file (e.g., generated file has + // `export { X as deserializeFoo } from '...'` but existing file + // already has `export const deserializeFoo = ...`). + const braceMatch = stmt.text.match(/\{([^}]+)\}/); + if (braceMatch) { + const exportedNames = braceMatch[1] + .split(',') + .map((n) => { + const parts = n.trim().split(/\s+as\s+/); + return (parts[1] ?? parts[0]).trim(); + }) + .filter(Boolean); + if (exportedNames.length > 0 && exportedNames.every((n) => existingExportedNames.has(n))) { + preserved++; + continue; + } + } } if (stmt.key && (existingKeys.has(stmt.key) || existingImportedNames.has(stmt.key))) { @@ -526,10 +598,11 @@ export async function mergeIntoExisting( if (existInfo.docstring) { const isPreserved = existInfo.docstring.text.includes('@oagen-ignore'); if (!isPreserved && existInfo.docstring.text !== genDoc.text) { + const newText = preserveDeprecatedTags(existInfo.docstring.text, genDoc.text); edits.push({ start: existInfo.docstring.startIndex, end: existInfo.docstring.endIndex, - newText: genDoc.text, + newText, }); docstringUpdates++; } @@ -549,16 +622,18 @@ export async function mergeIntoExisting( const matchedExistMembers = new Set(); for (const [memberName, genMember] of genInfo.members) { const existMember = existInfo.members.get(memberName); - if (!existMember || !genMember.docstring) continue; + if (!existMember) continue; matchedExistMembers.add(memberName); + if (!genMember.docstring) continue; if (existMember.docstring) { const isPreserved = existMember.docstring.text.includes('@oagen-ignore'); if (!isPreserved && existMember.docstring.text !== genMember.docstring.text) { + const newText = preserveDeprecatedTags(existMember.docstring.text, genMember.docstring.text); edits.push({ start: existMember.docstring.startIndex, end: existMember.docstring.endIndex, - newText: genMember.docstring.text, + newText, }); docstringUpdates++; } @@ -578,36 +653,68 @@ export async function mergeIntoExisting( // Match generated members to existing members by URL pattern when // name-based matching fails (e.g., generated "find" vs existing "getOrganization" // both call this.workos.get('/organizations/${id}')). - for (const [_genName, genMember] of genInfo.members) { + // + // Safety: only attempt fingerprint matching when there is exactly ONE + // unmatched generated member and ONE unmatched existing member for a + // given fingerprint. When multiple members share the same URL path + // (e.g., POST and GET on /authorization/roles), fingerprint matching + // is ambiguous and would swap docstrings between methods. + const unmatchedGenByFp = new Map< + string, + { name: string; member: typeof genInfo.members extends Map ? V : never }[] + >(); + for (const [genName, genMember] of genInfo.members) { + if (matchedExistMembers.has(genName)) continue; // name-matched in pass 1 (as an exist member) — skip if (!genMember.docstring || !genMember.urlFingerprint) continue; - // Find unmatched existing member with the same URL fingerprint - for (const [existName, existMember] of existInfo.members) { - if (matchedExistMembers.has(existName)) continue; - if (!existMember.urlFingerprint || existMember.urlFingerprint !== genMember.urlFingerprint) continue; - if (existMember.docstring) { - const isPreserved = existMember.docstring.text.includes('@oagen-ignore'); - if (isPreserved) continue; - if (existMember.docstring.text !== genMember.docstring.text) { - edits.push({ - start: existMember.docstring.startIndex, - end: existMember.docstring.endIndex, - newText: genMember.docstring.text, - }); - docstringUpdates++; - } - } else { - const lineStart = existMember.declStartIndex - existMember.declColumn; - const indent = ' '.repeat(existMember.declColumn); + // Skip if this generated member was already name-matched + if (existInfo.members.has(genName)) continue; + const bucket = unmatchedGenByFp.get(genMember.urlFingerprint) ?? []; + bucket.push({ name: genName, member: genMember }); + unmatchedGenByFp.set(genMember.urlFingerprint, bucket); + } + const unmatchedExistByFp = new Map< + string, + { name: string; member: typeof existInfo.members extends Map ? V : never }[] + >(); + for (const [existName, existMember] of existInfo.members) { + if (matchedExistMembers.has(existName)) continue; + if (!existMember.urlFingerprint) continue; + const bucket = unmatchedExistByFp.get(existMember.urlFingerprint) ?? []; + bucket.push({ name: existName, member: existMember }); + unmatchedExistByFp.set(existMember.urlFingerprint, bucket); + } + for (const [fp, genBucket] of unmatchedGenByFp) { + if (genBucket.length !== 1) continue; // ambiguous — skip + const existBucket = unmatchedExistByFp.get(fp); + if (!existBucket || existBucket.length !== 1) continue; // ambiguous — skip + const genMember = genBucket[0].member; + const existEntry = existBucket[0]; + const existMember = existEntry.member; + if (!genMember.docstring) continue; + + if (existMember.docstring) { + const isPreserved = existMember.docstring.text.includes('@oagen-ignore'); + if (isPreserved) continue; + if (existMember.docstring.text !== genMember.docstring.text) { + const newText = preserveDeprecatedTags(existMember.docstring.text, genMember.docstring.text); edits.push({ - start: lineStart, - end: lineStart, - newText: indent + genMember.docstring.text + '\n', + start: existMember.docstring.startIndex, + end: existMember.docstring.endIndex, + newText, }); docstringUpdates++; } - matchedExistMembers.add(existName); - break; // Only match one existing member per generated member + } else { + const lineStart = existMember.declStartIndex - existMember.declColumn; + const indent = ' '.repeat(existMember.declColumn); + edits.push({ + start: lineStart, + end: lineStart, + newText: indent + genMember.docstring.text + '\n', + }); + docstringUpdates++; } + matchedExistMembers.add(existEntry.name); } } diff --git a/src/engine/types.ts b/src/engine/types.ts index 99a949c..7ab19fd 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -46,8 +46,6 @@ export interface Emitter { generateErrors(ctx: EmitterContext): GeneratedFile[]; - generateConfig(ctx: EmitterContext): GeneratedFile[]; - generateTypeSignatures?(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[]; generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[]; diff --git a/src/engine/writer.ts b/src/engine/writer.ts index a6ec97f..a5dcedb 100644 --- a/src/engine/writer.ts +++ b/src/engine/writer.ts @@ -390,13 +390,26 @@ function spliceExtraImports(existingLines: string[], resultLines: string[]): voi const existingFrom = collectFromImports(existingLines); const generatedFrom = collectFromImports(resultLines); + // Build a set of all identifiers imported by the generated file (across all modules) + // so we can detect when an existing barrel import's identifiers are covered by + // more-specific generated imports (e.g., `from x.y import Z` covers `from x import Z`). + const allGeneratedIds = new Set(); + for (const ids of generatedFrom.values()) { + for (const id of ids) allGeneratedIds.add(id); + } + const extraFromLines: string[] = []; for (const [mod, existIds] of existingFrom) { const genIds = generatedFrom.get(mod); if (!genIds) { - extraFromLines.push(`from ${mod} import ${[...existIds].join(', ')}`); + // The exact module isn't in the generated file — but check whether every + // identifier is already imported from a different (e.g., more-specific) module. + const extra = [...existIds].filter((id) => !allGeneratedIds.has(id)); + if (extra.length > 0) { + extraFromLines.push(`from ${mod} import ${extra.join(', ')}`); + } } else { - const extra = [...existIds].filter((id) => !genIds.has(id)); + const extra = [...existIds].filter((id) => !genIds.has(id) && !allGeneratedIds.has(id)); if (extra.length > 0) { extraFromLines.push(`from ${mod} import ${extra.join(', ')}`); } diff --git a/src/ir/operation-hints.ts b/src/ir/operation-hints.ts index b3247a5..2642233 100644 --- a/src/ir/operation-hints.ts +++ b/src/ir/operation-hints.ts @@ -54,6 +54,10 @@ export interface ResolvedOperation { mountOn: string; /** For split operations: one wrapper per union variant. */ wrappers?: ResolvedWrapper[]; + /** Constant defaults injected into query/body at runtime (from operation-level hint). */ + defaults: Record; + /** Fields the SDK reads from client config at runtime (from operation-level hint). */ + inferFromClient: string[]; } export interface ResolvedWrapper { @@ -263,6 +267,8 @@ export function resolveOperations( methodName, mountOn, wrappers, + defaults: hint?.defaults ?? {}, + inferFromClient: hint?.inferFromClient ?? [], }); } } diff --git a/src/ir/types.ts b/src/ir/types.ts index f75a0b3..feaa13f 100644 --- a/src/ir/types.ts +++ b/src/ir/types.ts @@ -83,6 +83,8 @@ export interface Parameter { deprecated?: boolean; default?: unknown; example?: unknown; + style?: 'form' | 'simple' | 'label' | 'matrix'; + explode?: boolean; } /** Type reference — the core type system of the IR */ diff --git a/src/parser/operations.ts b/src/parser/operations.ts index 015e139..12695fd 100644 --- a/src/parser/operations.ts +++ b/src/parser/operations.ts @@ -50,6 +50,8 @@ interface ParameterObject { schema?: SchemaObject; deprecated?: boolean; example?: unknown; + style?: string; + explode?: boolean; } interface RequestBodyObject { @@ -433,6 +435,8 @@ function extractParams( deprecated: p.deprecated || p.schema?.deprecated || undefined, default: p.schema?.default, example: p.example ?? p.schema?.example, + style: p.style as Parameter['style'], + explode: p.explode, })); } diff --git a/test/cli/config-loader.test.ts b/test/cli/config-loader.test.ts index 62b3a17..7bc717b 100644 --- a/test/cli/config-loader.test.ts +++ b/test/cli/config-loader.test.ts @@ -61,7 +61,6 @@ describe('applyConfig', () => { generateResources: () => [], generateClient: () => [], generateErrors: () => [], - generateConfig: () => [], generateTypeSignatures: () => [], generateTests: () => [], fileHeader: () => '', diff --git a/test/cli/init.test.ts b/test/cli/init.test.ts index 5a2cd2f..5e02f5f 100644 --- a/test/cli/init.test.ts +++ b/test/cli/init.test.ts @@ -64,7 +64,7 @@ describe('initCommand', () => { expect(pkg.scripts['sdk:extract:go']).toContain('--lang go'); }); - it('stub emitter has all Emitter methods and no contractVersion', async () => { + it('stub emitter has required Emitter methods and no contractVersion', async () => { await initCommand({ lang: 'ruby', project: tmpDir }); const content = readFileSync(resolve(tmpDir, 'src/ruby/index.ts'), 'utf-8'); @@ -75,7 +75,7 @@ describe('initCommand', () => { expect(content).toContain('generateResources'); expect(content).toContain('generateClient'); expect(content).toContain('generateErrors'); - expect(content).toContain('generateConfig'); + expect(content).not.toContain('generateConfig'); expect(content).toContain('generateTests'); expect(content).toContain('fileHeader'); expect(content).toContain('rubyEmitter'); diff --git a/test/engine/orchestrator-write.test.ts b/test/engine/orchestrator-write.test.ts index bf09c92..4e1b34c 100644 --- a/test/engine/orchestrator-write.test.ts +++ b/test/engine/orchestrator-write.test.ts @@ -15,7 +15,6 @@ function mockEmitter(): Emitter { generateResources: () => [], generateClient: () => [{ path: 'client.rb', content: 'class Client; end' }], generateErrors: () => [], - generateConfig: () => [], generateTypeSignatures: () => [], generateTests: () => [], fileHeader: () => '# Auto-generated', diff --git a/test/engine/orchestrator.test.ts b/test/engine/orchestrator.test.ts index e869d0a..ba1d23d 100644 --- a/test/engine/orchestrator.test.ts +++ b/test/engine/orchestrator.test.ts @@ -15,7 +15,6 @@ function mockEmitter(): Emitter { generateResources: () => [{ path: 'resources/users.rb', content: 'class Users; end' }], generateClient: () => [{ path: 'client.rb', content: 'class Client; end' }], generateErrors: () => [{ path: 'errors.rb', content: 'class APIError; end' }], - generateConfig: () => [{ path: 'config.rb', content: 'module Config; end' }], generateTypeSignatures: () => [{ path: 'sig/user.rbs', content: 'class User; end' }], generateTests: () => [{ path: 'test/test_users.rb', content: 'class TestUsers; end' }], fileHeader: () => '# Auto-generated', @@ -40,14 +39,13 @@ describe('generate', () => { outputDir: '/tmp/test', }); - expect(files).toHaveLength(8); + expect(files).toHaveLength(7); const paths = files.map((f) => f.path); expect(paths).toContain('models/user.rb'); expect(paths).toContain('models/status.rb'); expect(paths).toContain('resources/users.rb'); expect(paths).toContain('client.rb'); expect(paths).toContain('errors.rb'); - expect(paths).toContain('config.rb'); expect(paths).toContain('sig/user.rbs'); expect(paths).toContain('test/test_users.rb'); }); @@ -162,7 +160,7 @@ describe('generate', () => { outputDir, }); - expect(files).toHaveLength(8); + expect(files).toHaveLength(7); // Only output dir should have files const outputFile = await fs.readFile(path.join(outputDir, 'client.rb'), 'utf-8'); expect(outputFile).toMatch(/^# Auto-generated/); diff --git a/test/engine/registry.test.ts b/test/engine/registry.test.ts index af546ba..ec2b043 100644 --- a/test/engine/registry.test.ts +++ b/test/engine/registry.test.ts @@ -12,7 +12,6 @@ describe('emitter registry', () => { generateResources: () => [], generateClient: () => [], generateErrors: () => [], - generateConfig: () => [], generateTypeSignatures: () => [], generateTests: () => [], fileHeader: () => '', @@ -31,7 +30,6 @@ describe('emitter registry', () => { generateResources: () => [], generateClient: () => [], generateErrors: () => [], - generateConfig: () => [], generateTypeSignatures: () => [], generateTests: () => [], fileHeader: () => '', @@ -48,7 +46,6 @@ describe('emitter registry', () => { generateResources: () => [], generateClient: () => [], generateErrors: () => [], - generateConfig: () => [], generateTests: () => [], fileHeader: () => '', }; diff --git a/test/examples/reference-emitter/emitter.test.ts b/test/examples/reference-emitter/emitter.test.ts index 579f789..d9379c0 100644 --- a/test/examples/reference-emitter/emitter.test.ts +++ b/test/examples/reference-emitter/emitter.test.ts @@ -131,14 +131,6 @@ describe('reference emitter', () => { expect(files[0].content).toContain('class NotFoundError'); }); - it('generates config types', () => { - const files = typescriptEmitter.generateConfig(minimalCtx); - expect(files).toHaveLength(1); - expect(files[0].path).toBe('config.ts'); - expect(files[0].content).toContain('interface ClientConfig'); - expect(files[0].content).toContain('class BaseResource'); - }); - it('returns empty models file for empty input', () => { const files = typescriptEmitter.generateModels([], minimalCtx); expect(files).toHaveLength(0); diff --git a/test/examples/reference-emitter/integration.test.ts b/test/examples/reference-emitter/integration.test.ts index 9aef0ab..1240b84 100644 --- a/test/examples/reference-emitter/integration.test.ts +++ b/test/examples/reference-emitter/integration.test.ts @@ -22,8 +22,6 @@ describe('reference emitter integration', () => { expect(paths).toContain('enums.ts'); expect(paths).toContain('client.ts'); expect(paths).toContain('errors.ts'); - expect(paths).toContain('config.ts'); - // Resource files per service expect(paths.some((p) => p.startsWith('resources/'))).toBe(true); }); diff --git a/test/fixtures/oagen.config.ts b/test/fixtures/oagen.config.ts index ff71d2b..1bad4f4 100644 --- a/test/fixtures/oagen.config.ts +++ b/test/fixtures/oagen.config.ts @@ -12,7 +12,6 @@ export default { { path: `${ctx.namespace}/client.ts`, content: '// client' }, ], generateErrors: () => [], - generateConfig: () => [], generateTypeSignatures: () => [], generateTests: () => [], fileHeader: () => '// auto-generated', diff --git a/test/integration/pipeline.test.ts b/test/integration/pipeline.test.ts index 6ff429d..78b50bb 100644 --- a/test/integration/pipeline.test.ts +++ b/test/integration/pipeline.test.ts @@ -174,7 +174,6 @@ function mockEmitter(): Emitter { })), generateClient: () => [{ path: 'client.ts', content: 'export class Client {}' }], generateErrors: () => [{ path: 'errors.ts', content: 'export class ApiError {}' }], - generateConfig: () => [{ path: 'config.ts', content: 'export const config = {};' }], generateTypeSignatures: (spec) => [ ...spec.models.map((m) => ({ path: `types/${m.name.toLowerCase()}.d.ts`, content: '' })), ...spec.services.map((s) => ({ path: `types/${s.name.toLowerCase()}.d.ts`, content: '' })), diff --git a/test/ir/operation-hints.test.ts b/test/ir/operation-hints.test.ts index 76ed1c8..e93d942 100644 --- a/test/ir/operation-hints.test.ts +++ b/test/ir/operation-hints.test.ts @@ -332,4 +332,27 @@ describe('resolveOperations', () => { expect(resolved[0].methodName).toBe('get_jwks'); expect(resolved[0].mountOn).toBe('UserManagement'); }); + + it('propagates operation-level defaults and inferFromClient', () => { + const s = spec([svc('SSO', [op('get', '/sso/authorize')])]); + const hints: Record = { + 'GET /sso/authorize': { + name: 'get_authorization_url', + defaults: { response_type: 'code' }, + inferFromClient: ['client_id'], + }, + }; + + const resolved = resolveOperations(s, hints); + expect(resolved[0].defaults).toEqual({ response_type: 'code' }); + expect(resolved[0].inferFromClient).toEqual(['client_id']); + }); + + it('defaults to empty defaults and inferFromClient when no hint', () => { + const s = spec([svc('Users', [op('get', '/users')])]); + + const resolved = resolveOperations(s); + expect(resolved[0].defaults).toEqual({}); + expect(resolved[0].inferFromClient).toEqual([]); + }); });