@pikku/openapi-parser 0.12.9 → 0.12.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/codegen.js CHANGED
@@ -1,4 +1,4 @@
1
- import { schemaToZod, schemaVarName, sanitizeTypeName, createContext, } from './zod-codegen.js';
1
+ import { schemaToZod, schemaVarName, sanitizeTypeName, createContext, snakeToCamel, } from './zod-codegen.js';
2
2
  import { generateOperationNames, detectCommonPrefix, } from './naming.js';
3
3
  function safeKey(key) {
4
4
  return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
@@ -44,6 +44,130 @@ function getErrorClassesForResponses(errorResponses) {
44
44
  }
45
45
  return classes;
46
46
  }
47
+ /**
48
+ * Collect all component schema names directly referenced by an operation's
49
+ * params, request body, and response schema.
50
+ */
51
+ function collectOperationSchemaRefs(parsed, componentNames, schemaIdentityMap) {
52
+ const refs = new Set();
53
+ const allSchemas = [];
54
+ // Gather all schema objects from this operation
55
+ for (const p of [
56
+ ...parsed.pathParams,
57
+ ...parsed.queryParams,
58
+ ...parsed.headerParams,
59
+ ]) {
60
+ if (p.schema)
61
+ allSchemas.push(p.schema);
62
+ }
63
+ if (parsed.requestBody)
64
+ allSchemas.push(parsed.requestBody);
65
+ if (parsed.responseSchema)
66
+ allSchemas.push(parsed.responseSchema);
67
+ for (const schema of allSchemas) {
68
+ // Check if the schema itself IS a component schema (by object identity).
69
+ // collectSchemaRefs skips the root identity check, so we must do it here.
70
+ if (schema && typeof schema === 'object') {
71
+ const identityName = schemaIdentityMap.get(schema);
72
+ if (identityName && componentNames.has(identityName)) {
73
+ refs.add(identityName);
74
+ }
75
+ }
76
+ for (const ref of collectSchemaRefs(schema, componentNames, schemaIdentityMap)) {
77
+ refs.add(ref);
78
+ }
79
+ }
80
+ return refs;
81
+ }
82
+ /**
83
+ * Compute the transitive closure of schema dependencies.
84
+ * Given a set of schema names, expand it to include all schemas they depend on.
85
+ */
86
+ function transitiveClosure(schemaNames, allSchemas, componentNames, schemaIdentityMap) {
87
+ const result = new Set();
88
+ const queue = [...schemaNames];
89
+ while (queue.length > 0) {
90
+ const name = queue.pop();
91
+ if (result.has(name))
92
+ continue;
93
+ if (!allSchemas[name])
94
+ continue;
95
+ result.add(name);
96
+ const deps = collectSchemaRefs(allSchemas[name], componentNames, schemaIdentityMap);
97
+ for (const dep of deps) {
98
+ if (!result.has(dep))
99
+ queue.push(dep);
100
+ }
101
+ }
102
+ return result;
103
+ }
104
+ /**
105
+ * Partition component schemas into shared, single-use, and unused buckets.
106
+ *
107
+ * Algorithm:
108
+ * 1. For each operation, collect its directly referenced component schemas
109
+ * 2. Expand each operation's refs to include transitive deps
110
+ * 3. Count how many operations reference each schema (via transitive closure)
111
+ * 4. Schemas with 2+ operations → shared, 1 operation → single, 0 → unused
112
+ * 5. Single-use schemas whose transitive deps include shared schemas still go
113
+ * into single-use (the function file will import those shared deps)
114
+ */
115
+ function partitionSchemas(spec, schemaIdentityMap) {
116
+ const componentNames = new Set(Object.keys(spec.componentSchemas));
117
+ // Step 1: Direct refs per operation
118
+ const opDirectRefs = spec.operations.map((op) => collectOperationSchemaRefs(op, componentNames, schemaIdentityMap));
119
+ // Step 2: Transitive refs per operation
120
+ const opTransitiveRefs = opDirectRefs.map((directRefs) => transitiveClosure(directRefs, spec.componentSchemas, componentNames, schemaIdentityMap));
121
+ // Step 3: Count how many operations reference each schema
122
+ const refCount = new Map();
123
+ for (let i = 0; i < opTransitiveRefs.length; i++) {
124
+ for (const name of opTransitiveRefs[i]) {
125
+ if (!refCount.has(name))
126
+ refCount.set(name, new Set());
127
+ refCount.get(name).add(i);
128
+ }
129
+ }
130
+ // Step 4: Partition
131
+ const shared = new Set();
132
+ const single = new Map();
133
+ const unused = new Set();
134
+ for (const name of componentNames) {
135
+ const ops = refCount.get(name);
136
+ if (!ops || ops.size === 0) {
137
+ unused.add(name);
138
+ }
139
+ else if (ops.size === 1) {
140
+ single.set(name, [...ops][0]);
141
+ }
142
+ else {
143
+ shared.add(name);
144
+ }
145
+ }
146
+ // Step 5: If a single-use schema transitively depends on another single-use
147
+ // schema from a DIFFERENT operation, promote both to shared.
148
+ // This handles chains like: OpA → SchemaX → SchemaY ← OpB
149
+ // Also: single-use schemas that depend on other single-use schemas from the
150
+ // same operation stay single-use (they'll be inlined together).
151
+ let changed = true;
152
+ while (changed) {
153
+ changed = false;
154
+ for (const [name, opIdx] of [...single]) {
155
+ const deps = collectSchemaRefs(spec.componentSchemas[name], componentNames, schemaIdentityMap);
156
+ for (const dep of deps) {
157
+ if (single.has(dep) && single.get(dep) !== opIdx) {
158
+ // Dep is single-use but for a different operation — promote both
159
+ shared.add(dep);
160
+ single.delete(dep);
161
+ shared.add(name);
162
+ single.delete(name);
163
+ changed = true;
164
+ break;
165
+ }
166
+ }
167
+ }
168
+ }
169
+ return { shared, single, unused };
170
+ }
47
171
  export function generateAddonFromOpenAPI(spec, vars, flags) {
48
172
  const files = {};
49
173
  const { name } = vars;
@@ -52,7 +176,41 @@ export function generateAddonFromOpenAPI(spec, vars, flags) {
52
176
  for (const schemaName of Object.keys(spec.componentSchemas)) {
53
177
  schemaRefs.set(schemaName, schemaVarName(schemaName));
54
178
  }
55
- const ctx = createContext(schemaRefs);
179
+ // Build an identity map from resolved schema objects to their component names.
180
+ // After $ref resolution, many references become the same JS object as the
181
+ // component schema (or a spread-copy). This map lets the codegen detect those
182
+ // resolved references by object identity instead of relying on $ref strings.
183
+ const schemaIdentityMap = new Map();
184
+ for (const [schemaName, schema] of Object.entries(spec.componentSchemas)) {
185
+ if (schema && typeof schema === 'object') {
186
+ schemaIdentityMap.set(schema, schemaName);
187
+ }
188
+ }
189
+ // Partition schemas: shared (2+ ops), single-use (1 op), unused (0 ops)
190
+ const partition = partitionSchemas(spec, schemaIdentityMap);
191
+ // Build the set of schemas that go in the shared types file:
192
+ // shared schemas + their transitive dependencies
193
+ const componentNames = new Set(Object.keys(spec.componentSchemas));
194
+ const sharedWithDeps = transitiveClosure(partition.shared, spec.componentSchemas, componentNames, schemaIdentityMap);
195
+ // Any transitive dep of a shared schema that was single-use gets promoted to shared
196
+ for (const name of sharedWithDeps) {
197
+ partition.single.delete(name);
198
+ }
199
+ // Build a reduced componentSchemas for the types file (only shared schemas)
200
+ const sharedComponentSchemas = {};
201
+ for (const schemaName of sharedWithDeps) {
202
+ if (spec.componentSchemas[schemaName]) {
203
+ sharedComponentSchemas[schemaName] = spec.componentSchemas[schemaName];
204
+ }
205
+ }
206
+ // schemaRefs for shared types file context — only shared schemas
207
+ const sharedSchemaRefs = new Map();
208
+ for (const schemaName of sharedWithDeps) {
209
+ sharedSchemaRefs.set(schemaName, schemaVarName(schemaName));
210
+ }
211
+ const ctx = createContext(sharedSchemaRefs, schemaIdentityMap, {
212
+ camelCase: flags.camelCase,
213
+ });
56
214
  // Generate operation names
57
215
  const paths = spec.operations.map((op) => op.path);
58
216
  const commonPrefix = detectCommonPrefix(paths);
@@ -64,14 +222,35 @@ export function generateAddonFromOpenAPI(spec, vars, flags) {
64
222
  // Pair named operations with their parsed data
65
223
  const opPairs = namedOps.map((named, i) => ({ named, parsed: spec.operations[i] }));
66
224
  // Generate types file with shared schemas (only if there are any)
67
- if (Object.keys(spec.componentSchemas).length > 0) {
68
- files[`src/${name}.types.ts`] = generateTypesFile(spec, ctx);
225
+ if (Object.keys(sharedComponentSchemas).length > 0) {
226
+ files[`src/${name}.types.ts`] = generateTypesFile({ ...spec, componentSchemas: sharedComponentSchemas }, ctx, schemaIdentityMap);
227
+ }
228
+ // Collect single-use schemas per operation index
229
+ const singleSchemasPerOp = new Map();
230
+ for (const [schemaName, opIdx] of partition.single) {
231
+ if (!singleSchemasPerOp.has(opIdx))
232
+ singleSchemasPerOp.set(opIdx, new Set());
233
+ singleSchemasPerOp.get(opIdx).add(schemaName);
234
+ }
235
+ // Expand single-use schemas to include their transitive deps that are also single-use
236
+ for (const [_opIdx, schemas] of singleSchemasPerOp) {
237
+ const expanded = transitiveClosure(schemas, spec.componentSchemas, componentNames, schemaIdentityMap);
238
+ // Only keep schemas that are NOT in the shared set
239
+ for (const s of expanded) {
240
+ if (!sharedWithDeps.has(s) && !partition.unused.has(s)) {
241
+ schemas.add(s);
242
+ }
243
+ }
69
244
  }
70
245
  // Generate function files
71
246
  const functionExports = [];
72
- for (const { named, parsed } of opPairs) {
73
- const funcCtx = createContext(schemaRefs);
74
- const funcCode = generateFunctionFile(named, parsed, vars, funcCtx, spec, flags);
247
+ for (let i = 0; i < opPairs.length; i++) {
248
+ const { named, parsed } = opPairs[i];
249
+ const funcCtx = createContext(schemaRefs, schemaIdentityMap, {
250
+ camelCase: flags.camelCase,
251
+ });
252
+ const inlineSchemas = singleSchemasPerOp.get(i) ?? new Set();
253
+ const funcCode = generateFunctionFile(named, parsed, vars, funcCtx, spec, flags, inlineSchemas, sharedWithDeps, schemaIdentityMap);
75
254
  files[`src/functions/${named.functionName}.function.ts`] = funcCode;
76
255
  functionExports.push(named.functionName);
77
256
  }
@@ -83,43 +262,216 @@ export function generateAddonFromOpenAPI(spec, vars, flags) {
83
262
  files[`src/${name}.variable.ts`] = generateVariableFile(spec, vars);
84
263
  return files;
85
264
  }
86
- function generateTypesFile(spec, ctx) {
265
+ /**
266
+ * Collect all component schema names referenced (directly or via properties/items/allOf/oneOf/anyOf)
267
+ * by a given OpenAPI schema object.
268
+ *
269
+ * After $ref resolution, most $ref strings are gone and replaced by the resolved
270
+ * object (often the same JS identity as the component schema). We detect
271
+ * dependencies both by surviving $ref strings AND by object identity via
272
+ * schemaIdentityMap.
273
+ */
274
+ function collectSchemaRefs(schema, componentNames, schemaIdentityMap) {
275
+ const refs = new Set();
276
+ const visited = new Set();
277
+ function walk(node, isRoot) {
278
+ if (!node || typeof node !== 'object' || visited.has(node))
279
+ return;
280
+ visited.add(node);
281
+ // Check surviving $ref strings
282
+ if (node.$ref) {
283
+ const refName = node.$ref.split('/').pop();
284
+ if (componentNames.has(refName))
285
+ refs.add(refName);
286
+ }
287
+ // Check if this node IS a known component schema (resolved $ref by identity).
288
+ // Skip this check for the root node (the schema we're analyzing),
289
+ // otherwise we'd match the schema against itself and return early,
290
+ // missing its internal dependencies.
291
+ if (!isRoot) {
292
+ const identityName = schemaIdentityMap.get(node);
293
+ if (identityName && componentNames.has(identityName)) {
294
+ refs.add(identityName);
295
+ // Don't walk into the component schema's internals — those are
296
+ // handled when that schema is processed as a top-level entry.
297
+ return;
298
+ }
299
+ }
300
+ if (node.properties) {
301
+ for (const prop of Object.values(node.properties))
302
+ walk(prop, false);
303
+ }
304
+ if (node.items)
305
+ walk(node.items, false);
306
+ if (node.allOf)
307
+ for (const s of node.allOf)
308
+ walk(s, false);
309
+ if (node.oneOf)
310
+ for (const s of node.oneOf)
311
+ walk(s, false);
312
+ if (node.anyOf)
313
+ for (const s of node.anyOf)
314
+ walk(s, false);
315
+ if (node.additionalProperties &&
316
+ typeof node.additionalProperties === 'object') {
317
+ walk(node.additionalProperties, false);
318
+ }
319
+ }
320
+ walk(schema, true);
321
+ return refs;
322
+ }
323
+ /**
324
+ * Topologically sort component schemas so that dependencies come before dependents.
325
+ * Schemas involved in cycles are placed in arbitrary order (cycles are handled via z.lazy).
326
+ */
327
+ function topoSortSchemas(schemas, schemaIdentityMap) {
328
+ const names = new Set(Object.keys(schemas));
329
+ const deps = new Map();
330
+ const selfRefs = new Set();
331
+ for (const [name, schema] of Object.entries(schemas)) {
332
+ const refNames = collectSchemaRefs(schema, names, schemaIdentityMap);
333
+ if (refNames.has(name)) {
334
+ selfRefs.add(name); // track self-referential schemas
335
+ }
336
+ refNames.delete(name); // remove self-refs from dependency graph
337
+ deps.set(name, refNames);
338
+ }
339
+ const sorted = [];
340
+ const visited = new Set();
341
+ const inStack = new Set();
342
+ const cyclicEdges = new Set(); // "child" schemas that are referenced before declaration
343
+ function visit(name) {
344
+ if (visited.has(name))
345
+ return;
346
+ if (inStack.has(name)) {
347
+ // This is a cycle — mark this name as needing z.lazy()
348
+ cyclicEdges.add(name);
349
+ return;
350
+ }
351
+ inStack.add(name);
352
+ const children = deps.get(name) ?? new Set();
353
+ for (const child of children) {
354
+ if (names.has(child))
355
+ visit(child);
356
+ }
357
+ inStack.delete(name);
358
+ visited.add(name);
359
+ sorted.push(name);
360
+ }
361
+ for (const name of names) {
362
+ visit(name);
363
+ }
364
+ // Self-referential schemas also need z.lazy() forward declarations
365
+ for (const name of selfRefs) {
366
+ cyclicEdges.add(name);
367
+ }
368
+ return { sorted, cyclicEdges };
369
+ }
370
+ function generateTypesFile(spec, ctx, schemaIdentityMap) {
87
371
  const lines = [];
88
372
  lines.push("import { z } from 'zod'");
89
373
  lines.push('');
90
374
  lines.push(`// Shared schemas from ${spec.info.title} v${spec.info.version}`);
91
375
  lines.push('');
92
- for (const [name, schema] of Object.entries(spec.componentSchemas)) {
93
- const varName = schemaVarName(name);
376
+ const { sorted, cyclicEdges } = topoSortSchemas(spec.componentSchemas, schemaIdentityMap);
377
+ // Track emitted variable names and type names to deduplicate collisions
378
+ // (e.g. two OpenAPI components that sanitize to the same JS identifier)
379
+ const emittedVarNames = new Set();
380
+ const emittedTypeNames = new Set();
381
+ function deduplicateName(baseName, usedSet) {
382
+ if (!usedSet.has(baseName)) {
383
+ usedSet.add(baseName);
384
+ return baseName;
385
+ }
386
+ let counter = 2;
387
+ while (usedSet.has(`${baseName}_${counter}`)) {
388
+ counter++;
389
+ }
390
+ const deduped = `${baseName}_${counter}`;
391
+ usedSet.add(deduped);
392
+ return deduped;
393
+ }
394
+ // For schemas that participate in cycles, emit a z.lazy() forward declaration
395
+ // before any schemas are defined, so forward references work.
396
+ if (cyclicEdges.size > 0) {
397
+ lines.push('// Forward declarations for circular references');
398
+ for (const name of cyclicEdges) {
399
+ const varName = deduplicateName(schemaVarName(name), emittedVarNames);
400
+ const typeName = deduplicateName(sanitizeTypeName(name), emittedTypeNames);
401
+ lines.push(`export const ${varName}: z.ZodType<any> = z.lazy(() => _${varName})`);
402
+ lines.push(`export type ${typeName} = z.infer<typeof ${varName}>`);
403
+ lines.push('');
404
+ }
405
+ }
406
+ // Emit schemas in topological order
407
+ for (const name of sorted) {
408
+ const schema = spec.componentSchemas[name];
409
+ const varName = deduplicateName(schemaVarName(name), emittedVarNames);
410
+ const typeName = deduplicateName(sanitizeTypeName(name), emittedTypeNames);
411
+ // Temporarily remove the current schema from the identity map so that
412
+ // schemaToZod doesn't short-circuit to referencing the variable we're
413
+ // currently defining (which would produce `const XSchema = XSchema`).
414
+ ctx.schemaIdentityMap.delete(schema);
94
415
  const zodCode = schemaToZod(schema, ctx);
95
- lines.push(`export const ${varName} = ${zodCode}`);
96
- lines.push(`export type ${sanitizeTypeName(name)} = z.infer<typeof ${varName}>`);
416
+ // Restore it for subsequent schemas that may reference this one.
417
+ ctx.schemaIdentityMap.set(schema, name);
418
+ if (cyclicEdges.has(name)) {
419
+ // The public variable was already declared above via z.lazy();
420
+ // emit the real implementation with an underscore prefix.
421
+ lines.push(`const _${varName} = ${zodCode}`);
422
+ }
423
+ else {
424
+ lines.push(`export const ${varName} = ${zodCode}`);
425
+ lines.push(`export type ${typeName} = z.infer<typeof ${varName}>`);
426
+ }
97
427
  lines.push('');
98
428
  }
99
429
  return lines.join('\n');
100
430
  }
101
- function generateFunctionFile(named, parsed, vars, ctx, spec, flags) {
431
+ function generateFunctionFile(named, parsed, vars, ctx, spec, flags, inlineSchemas = new Set(), sharedSchemas = new Set(), schemaIdentityMap = new Map()) {
102
432
  const lines = [];
103
- const { camelName } = vars;
433
+ const { camelName, name } = vars;
434
+ const hasInput = parsed.pathParams.length > 0 ||
435
+ parsed.queryParams.length > 0 ||
436
+ parsed.headerParams.length > 0 ||
437
+ parsed.requestBody;
438
+ const pascalName = named.functionName.charAt(0).toUpperCase() + named.functionName.slice(1);
439
+ const inputName = `${pascalName}Input`;
440
+ const outputName = `${pascalName}Output`;
441
+ // Pre-generate Zod code to discover which schema refs are used
442
+ let inputCode;
443
+ let outputCode;
444
+ if (hasInput) {
445
+ inputCode = buildInputSchema(parsed, ctx);
446
+ }
447
+ if (parsed.responseSchema) {
448
+ outputCode = buildOutputSchema(parsed.responseSchema, ctx);
449
+ }
450
+ // Bug 2: If generated Zod code is too large (> 500 lines), TypeScript
451
+ // can't infer the type (TS7056). Replace with z.any() to stay compilable.
452
+ const LINE_LIMIT = 500;
453
+ if (inputCode && inputCode.split('\n').length > LINE_LIMIT) {
454
+ inputCode = 'z.any()';
455
+ }
456
+ if (outputCode && outputCode.split('\n').length > LINE_LIMIT) {
457
+ outputCode = 'z.any()';
458
+ }
104
459
  // Tag description as file header
105
460
  if (parsed.tags.length > 0) {
106
461
  const tag = parsed.tags[0];
107
462
  const tagDesc = spec.tagDescriptions[tag];
108
463
  if (tagDesc) {
109
- lines.push(`// ${tag} — ${tagDesc}`);
464
+ const cleanDesc = tagDesc
465
+ .replace(/\r?\n/g, ' ')
466
+ .replace(/\s+/g, ' ')
467
+ .trim();
468
+ lines.push(`// ${tag} — ${cleanDesc}`);
110
469
  lines.push('');
111
470
  }
112
471
  }
113
- const hasInput = parsed.pathParams.length > 0 ||
114
- parsed.queryParams.length > 0 ||
115
- parsed.headerParams.length > 0 ||
116
- parsed.requestBody;
117
- const pascalName = named.functionName.charAt(0).toUpperCase() + named.functionName.slice(1);
118
- const inputName = `${pascalName}Input`;
119
- const outputName = `${pascalName}Output`;
120
472
  // Determine error imports needed
121
473
  const errorClasses = getErrorClassesForResponses(parsed.errorResponses);
122
- const needsZod = hasInput || !!parsed.responseSchema;
474
+ const needsZod = hasInput || !!parsed.responseSchema || inlineSchemas.size > 0;
123
475
  if (needsZod) {
124
476
  lines.push("import { z } from 'zod'");
125
477
  }
@@ -127,16 +479,122 @@ function generateFunctionFile(named, parsed, vars, ctx, spec, flags) {
127
479
  if (errorClasses.length > 0) {
128
480
  lines.push(`import { ${errorClasses.join(', ')} } from '@pikku/core/errors'`);
129
481
  }
482
+ // Import referenced component schemas from the types file.
483
+ // Only import schemas that are in the shared set (not inlined ones).
484
+ // In addition to the refs tracked through usedRefs, scan the generated Zod
485
+ // code for schema variable names that may have been missed (e.g. when
486
+ // a $ref survived resolution in a nested path not covered by usedRefs).
487
+ if (Object.keys(spec.componentSchemas).length > 0) {
488
+ const allGeneratedCode = [inputCode, outputCode].filter(Boolean).join('\n');
489
+ for (const [refName, varName] of ctx.schemaRefs) {
490
+ if (!ctx.usedRefs.has(refName) && allGeneratedCode.includes(varName)) {
491
+ ctx.usedRefs.add(refName);
492
+ }
493
+ }
494
+ const schemaImports = [
495
+ ...new Set([...ctx.usedRefs]
496
+ .filter((refName) => sharedSchemas.has(refName)) // only import shared schemas
497
+ .map((refName) => ctx.schemaRefs.get(refName))
498
+ .filter(Boolean)),
499
+ ].sort();
500
+ if (schemaImports.length > 0) {
501
+ lines.push(`import { ${schemaImports.join(', ')} } from '../${name}.types.js'`);
502
+ }
503
+ }
130
504
  lines.push('');
505
+ // Emit inline (single-use) schema definitions before input/output
506
+ if (inlineSchemas.size > 0) {
507
+ // Build a mini component schemas map for just the inline schemas
508
+ const inlineComponentSchemas = {};
509
+ for (const schemaName of inlineSchemas) {
510
+ if (spec.componentSchemas[schemaName]) {
511
+ inlineComponentSchemas[schemaName] = spec.componentSchemas[schemaName];
512
+ }
513
+ }
514
+ if (Object.keys(inlineComponentSchemas).length > 0) {
515
+ // Create a context that knows about both shared (for imports) and inline schemas
516
+ const inlineCtx = createContext(ctx.schemaRefs, schemaIdentityMap, {
517
+ camelCase: flags.camelCase,
518
+ });
519
+ const { sorted, cyclicEdges } = topoSortSchemas(inlineComponentSchemas, schemaIdentityMap);
520
+ // Bug 4: Pre-generate all Zod code and detect forward references.
521
+ // The topological sort may miss dependencies when $ref resolution
522
+ // replaces references with copies (breaking object identity).
523
+ // Scan generated code for variable names that appear before their
524
+ // declaration and promote those to z.lazy() forward declarations.
525
+ const inlineSorted = sorted.filter((s) => inlineSchemas.has(s));
526
+ const generatedCode = new Map();
527
+ for (const sName of inlineSorted) {
528
+ const schema = spec.componentSchemas[sName];
529
+ inlineCtx.schemaIdentityMap.delete(schema);
530
+ const zodCode = schemaToZod(schema, inlineCtx);
531
+ inlineCtx.schemaIdentityMap.set(schema, sName);
532
+ generatedCode.set(sName, zodCode);
533
+ }
534
+ // Build a map of variable names to schema names for inline schemas
535
+ const inlineVarToName = new Map();
536
+ for (const sName of inlineSorted) {
537
+ inlineVarToName.set(schemaVarName(sName), sName);
538
+ }
539
+ // Detect forward references and self-references: for each schema in order,
540
+ // check if its generated code references a variable that appears later in
541
+ // the sorted list OR references its own variable (self-referential schema).
542
+ const forwardRefs = new Set(cyclicEdges);
543
+ const emittedSoFar = new Set();
544
+ for (const sName of inlineSorted) {
545
+ const code = generatedCode.get(sName);
546
+ const ownVarN = schemaVarName(sName);
547
+ // Self-reference: schema uses its own variable name in its definition
548
+ if (code.includes(ownVarN)) {
549
+ forwardRefs.add(sName);
550
+ }
551
+ for (const [varN, refName] of inlineVarToName) {
552
+ if (refName !== sName &&
553
+ !emittedSoFar.has(refName) &&
554
+ code.includes(varN)) {
555
+ // This schema references another inline schema not yet emitted
556
+ forwardRefs.add(refName);
557
+ }
558
+ }
559
+ emittedSoFar.add(sName);
560
+ }
561
+ const emittedVarNames = new Set();
562
+ const emittedTypeNames = new Set();
563
+ // Forward declarations for cycles and forward references within inline schemas
564
+ if (forwardRefs.size > 0) {
565
+ lines.push('// Forward declarations for circular references');
566
+ for (const sName of forwardRefs) {
567
+ if (!inlineSchemas.has(sName))
568
+ continue;
569
+ const varN = schemaVarName(sName);
570
+ const typeN = sanitizeTypeName(sName);
571
+ emittedVarNames.add(varN);
572
+ emittedTypeNames.add(typeN);
573
+ lines.push(`const ${varN}: z.ZodType<any> = z.lazy(() => _${varN})`);
574
+ lines.push('');
575
+ }
576
+ }
577
+ for (const sName of inlineSorted) {
578
+ const varN = schemaVarName(sName);
579
+ emittedVarNames.add(varN);
580
+ const zodCode = generatedCode.get(sName);
581
+ if (forwardRefs.has(sName)) {
582
+ lines.push(`const _${varN} = ${zodCode}`);
583
+ }
584
+ else {
585
+ lines.push(`const ${varN} = ${zodCode}`);
586
+ }
587
+ lines.push('');
588
+ }
589
+ }
590
+ }
131
591
  // Build Input schema (exported for pikku schema discovery)
132
- if (hasInput) {
133
- const inputCode = buildInputSchema(parsed, ctx);
592
+ if (hasInput && inputCode) {
134
593
  lines.push(`export const ${inputName} = ${inputCode}`);
135
594
  lines.push('');
136
595
  }
137
596
  // Build Output schema (exported for pikku schema discovery)
138
- if (parsed.responseSchema) {
139
- const outputCode = buildOutputSchema(parsed.responseSchema, ctx);
597
+ if (parsed.responseSchema && outputCode) {
140
598
  lines.push(`export const ${outputName} = ${outputCode}`);
141
599
  lines.push('');
142
600
  }
@@ -157,9 +615,13 @@ function generateFunctionFile(named, parsed, vars, ctx, spec, flags) {
157
615
  if (flags.mcp) {
158
616
  funcConfig.push(' mcp: true,');
159
617
  }
160
- const funcParams = hasInput ? `{ ${camelName} }, data` : `{ ${camelName} }`;
618
+ // Bug 3: Avoid duplicate identifier when camelName is 'data' (TS2300)
619
+ const inputParamName = camelName === 'data' ? 'inputData' : 'data';
620
+ const funcParams = hasInput
621
+ ? `{ ${camelName} }, ${inputParamName}`
622
+ : `{ ${camelName} }`;
161
623
  const returnCast = parsed.responseSchema ? ' as any' : '';
162
- funcConfig.push(` func: async (${funcParams}) => {`, ` return ${camelName}.call(${JSON.stringify(method)}, ${JSON.stringify(parsed.path)}${hasInput ? ', data' : ''})${returnCast}`, ' },');
624
+ funcConfig.push(` func: async (${funcParams}) => {`, ` return ${camelName}.call(${JSON.stringify(method)}, ${JSON.stringify(parsed.path)}${hasInput ? `, ${inputParamName}` : ''})${returnCast}`, ' },');
163
625
  lines.push(`export const ${named.functionName} = pikkuSessionlessFunc({`);
164
626
  lines.push(funcConfig.join('\n'));
165
627
  lines.push('})');
@@ -168,35 +630,55 @@ function generateFunctionFile(named, parsed, vars, ctx, spec, flags) {
168
630
  }
169
631
  function buildInputSchema(parsed, ctx) {
170
632
  const props = [];
171
- for (const param of parsed.pathParams) {
172
- props.push(formatParamProp(param, ctx));
633
+ // Track which parameter names have been emitted to prevent duplicates.
634
+ // When shared path-level params and operation-level params overlap
635
+ // (e.g. both define "Version"), the later (operation-level) one wins.
636
+ const emittedNames = new Set();
637
+ // Deduplicate params: operation-level params override shared path-level
638
+ // params with the same name. Build deduplicated lists per category.
639
+ function deduplicateParams(params) {
640
+ const seen = new Map();
641
+ for (const param of params) {
642
+ seen.set(param.name, param); // last wins (operation-level comes after shared)
643
+ }
644
+ return [...seen.values()];
173
645
  }
174
- for (const param of parsed.queryParams) {
175
- props.push(formatParamProp(param, ctx));
646
+ const useCamel = !!ctx.camelCase;
647
+ for (const param of deduplicateParams(parsed.pathParams)) {
648
+ if (!emittedNames.has(param.name)) {
649
+ emittedNames.add(param.name);
650
+ props.push(formatParamProp(param, ctx, useCamel));
651
+ }
176
652
  }
177
- for (const param of parsed.headerParams) {
178
- props.push(formatParamProp(param, ctx));
653
+ for (const param of deduplicateParams(parsed.queryParams)) {
654
+ if (!emittedNames.has(param.name)) {
655
+ emittedNames.add(param.name);
656
+ props.push(formatParamProp(param, ctx, useCamel));
657
+ }
658
+ }
659
+ for (const param of deduplicateParams(parsed.headerParams)) {
660
+ if (!emittedNames.has(param.name)) {
661
+ emittedNames.add(param.name);
662
+ props.push(formatParamProp(param, ctx, useCamel));
663
+ }
179
664
  }
180
665
  if (parsed.requestBody) {
181
666
  if (parsed.requestBody.properties) {
182
- const paramNames = new Set([
183
- ...parsed.pathParams.map((p) => p.name),
184
- ...parsed.queryParams.map((p) => p.name),
185
- ...parsed.headerParams.map((p) => p.name),
186
- ]);
187
667
  const requiredSet = new Set(parsed.requestBody.required ?? []);
188
668
  for (const [key, propSchema] of Object.entries(parsed.requestBody.properties)) {
189
669
  // Skip readOnly properties from input
190
670
  if (propSchema.readOnly)
191
671
  continue;
192
- // Skip body properties that collide with path/query/header params
193
- if (paramNames.has(key)) {
672
+ // Skip body properties that collide with already-emitted params
673
+ if (emittedNames.has(key)) {
194
674
  console.warn(`[openapi] Skipping body property '${key}' — collides with path/query/header param`);
195
675
  continue;
196
676
  }
677
+ emittedNames.add(key);
197
678
  const isOptional = !requiredSet.has(key);
198
679
  const zodCode = schemaToZod(propSchema, ctx, { optional: isOptional });
199
- props.push(` ${safeKey(key)}: ${zodCode},`);
680
+ const outputKey = useCamel ? snakeToCamel(key) : key;
681
+ props.push(` ${safeKey(outputKey)}: ${zodCode},`);
200
682
  }
201
683
  }
202
684
  else {
@@ -206,29 +688,35 @@ function buildInputSchema(parsed, ctx) {
206
688
  }
207
689
  return `z.object({\n${props.join('\n')}\n})`;
208
690
  }
209
- function formatParamProp(param, ctx) {
691
+ function formatParamProp(param, ctx, camelCase = false) {
210
692
  const zodCode = schemaToZod(param.schema, ctx, { optional: !param.required });
211
693
  let descParts = [];
212
694
  if (param.description)
213
- descParts.push(param.description);
695
+ descParts.push(param.description.replace(/\*\//g, '* /'));
214
696
  if (param.example !== undefined)
215
697
  descParts.push(`Example: ${JSON.stringify(param.example)}`);
216
698
  const desc = descParts.length > 0
217
699
  ? `${zodCode}.describe(${JSON.stringify(descParts.join('. '))})`
218
700
  : zodCode;
219
- return ` ${safeKey(param.name)}: ${desc},`;
701
+ const outputKey = camelCase ? snakeToCamel(param.name) : param.name;
702
+ return ` ${safeKey(outputKey)}: ${desc},`;
220
703
  }
221
704
  function buildOutputSchema(schema, ctx) {
222
- // For output schemas, filter out writeOnly properties
705
+ // For output schemas, filter out writeOnly properties.
706
+ // Only create a filtered copy if there are actually writeOnly properties,
707
+ // to preserve object identity for schema identity map matching.
223
708
  if (schema.properties) {
224
- const filteredProps = {};
225
- for (const [key, propSchema] of Object.entries(schema.properties)) {
226
- if (!propSchema.writeOnly) {
227
- filteredProps[key] = propSchema;
709
+ const hasWriteOnly = Object.values(schema.properties).some((p) => p.writeOnly);
710
+ if (hasWriteOnly) {
711
+ const filteredProps = {};
712
+ for (const [key, propSchema] of Object.entries(schema.properties)) {
713
+ if (!propSchema.writeOnly) {
714
+ filteredProps[key] = propSchema;
715
+ }
228
716
  }
717
+ const filteredSchema = { ...schema, properties: filteredProps };
718
+ return schemaToZod(filteredSchema, ctx);
229
719
  }
230
- const filteredSchema = { ...schema, properties: filteredProps };
231
- return schemaToZod(filteredSchema, ctx);
232
720
  }
233
721
  return schemaToZod(schema, ctx);
234
722
  }
@@ -241,7 +729,8 @@ function generateIndexFile(functionExports) {
241
729
  return lines.join('\n');
242
730
  }
243
731
  function generateServiceFile(spec, opPairs, vars, flags) {
244
- const { name, pascalName, screamingName, displayName } = vars;
732
+ const { name, pascalName, screamingName } = vars;
733
+ const displayName = vars.displayName.replace(/'/g, '');
245
734
  const lines = [];
246
735
  // Always import all error classes used in the switch statement
247
736
  const allErrorClasses = new Set(Object.values(STATUS_TO_ERROR));
@@ -296,6 +785,20 @@ function generateServiceFile(spec, opPairs, vars, flags) {
296
785
  }
297
786
  lines.push(`const ROUTES: Record<string, { path: string[], query: string[], headers: string[], errors?: Record<number, string> }> = ${JSON.stringify(routes, null, 2)}`);
298
787
  lines.push('');
788
+ if (flags.camelCase) {
789
+ lines.push('function _toSnakeCase(data: Record<string, unknown>): Record<string, unknown> {');
790
+ lines.push(' return Object.fromEntries(');
791
+ lines.push(' Object.entries(data).map(([k, v]) => [k.replace(/[A-Z]/g, c => `_${c.toLowerCase()}`), v])');
792
+ lines.push(' )');
793
+ lines.push('}');
794
+ lines.push('');
795
+ lines.push('function _toCamelCase(data: Record<string, unknown>): Record<string, unknown> {');
796
+ lines.push(' return Object.fromEntries(');
797
+ lines.push(' Object.entries(data).map(([k, v]) => [k.replace(/[-_]+(.)/g, (_, c) => c.toUpperCase()), v])');
798
+ lines.push(' )');
799
+ lines.push('}');
800
+ lines.push('');
801
+ }
299
802
  // Class declaration
300
803
  lines.push(`export class ${pascalName}Service {`);
301
804
  lines.push(' private baseUrl: string');
@@ -337,6 +840,9 @@ function generateServiceFile(spec, opPairs, vars, flags) {
337
840
  lines.push(' path: string,');
338
841
  lines.push(' data?: Record<string, unknown>');
339
842
  lines.push(' ): Promise<T> {');
843
+ if (flags.camelCase) {
844
+ lines.push(' const rawData = data ? _toSnakeCase(data) : data');
845
+ }
340
846
  lines.push(' const route = ROUTES[`${method} ${path}`]');
341
847
  lines.push(' let endpoint = path');
342
848
  lines.push(' let body: Record<string, unknown> | undefined');
@@ -345,29 +851,31 @@ function generateServiceFile(spec, opPairs, vars, flags) {
345
851
  lines.push(" 'Content-Type': 'application/json',");
346
852
  lines.push(' }');
347
853
  lines.push('');
348
- lines.push(' if (data && route) {');
854
+ // When camelCase flag is on, use rawData (snake_case converted) for route matching
855
+ const dataVar = flags.camelCase ? 'rawData' : 'data';
856
+ lines.push(` if (${dataVar} && route) {`);
349
857
  lines.push(' // Interpolate path params');
350
858
  lines.push(' for (const param of route.path) {');
351
- lines.push(' if (data[param] !== undefined) {');
352
- lines.push(' endpoint = endpoint.replace(`{${param}}`, String(data[param]))');
859
+ lines.push(` if (${dataVar}[param] !== undefined) {`);
860
+ lines.push(` endpoint = endpoint.replace(\`{\${param}}\`, String(${dataVar}[param]))`);
353
861
  lines.push(' }');
354
862
  lines.push(' }');
355
863
  lines.push(' // Extract query params');
356
864
  lines.push(' for (const param of route.query) {');
357
- lines.push(' if (data[param] !== undefined) {');
358
- lines.push(' query[param] = String(data[param])');
865
+ lines.push(` if (${dataVar}[param] !== undefined) {`);
866
+ lines.push(` query[param] = String(${dataVar}[param])`);
359
867
  lines.push(' }');
360
868
  lines.push(' }');
361
869
  lines.push(' // Extract header params');
362
870
  lines.push(' for (const param of route.headers) {');
363
- lines.push(' if (data[param] !== undefined) {');
364
- lines.push(' headers[param] = String(data[param])');
871
+ lines.push(` if (${dataVar}[param] !== undefined) {`);
872
+ lines.push(` headers[param] = String(${dataVar}[param])`);
365
873
  lines.push(' }');
366
874
  lines.push(' }');
367
875
  lines.push(' // Everything else goes into body');
368
876
  lines.push(' const pathQueryHeaders = new Set([...route.path, ...route.query, ...route.headers])');
369
877
  lines.push(' const remaining = Object.fromEntries(');
370
- lines.push(' Object.entries(data).filter(([k]) => !pathQueryHeaders.has(k))');
878
+ lines.push(` Object.entries(${dataVar}).filter(([k]) => !pathQueryHeaders.has(k))`);
371
879
  lines.push(' )');
372
880
  lines.push(' if (Object.keys(remaining).length > 0) {');
373
881
  lines.push(' body = remaining');
@@ -451,14 +959,21 @@ function generateServiceFile(spec, opPairs, vars, flags) {
451
959
  lines.push('');
452
960
  lines.push(' const text = await response.text()');
453
961
  lines.push(' if (!text) return {} as T');
454
- lines.push(' return JSON.parse(text) as T');
962
+ if (flags.camelCase) {
963
+ lines.push(' const result = JSON.parse(text)');
964
+ lines.push(' return (typeof result === "object" && result !== null && !Array.isArray(result) ? _toCamelCase(result) : result) as T');
965
+ }
966
+ else {
967
+ lines.push(' return JSON.parse(text) as T');
968
+ }
455
969
  lines.push(' }');
456
970
  lines.push('}');
457
971
  lines.push('');
458
972
  return lines.join('\n');
459
973
  }
460
974
  function generateVariableFile(spec, vars) {
461
- const { camelName, screamingName, displayName } = vars;
975
+ const { camelName, screamingName } = vars;
976
+ const displayName = vars.displayName.replace(/'/g, '');
462
977
  const serverUrls = spec.serverUrls.length > 0 ? spec.serverUrls : [];
463
978
  const defaultUrl = serverUrls[0];
464
979
  const lines = [];
@@ -466,8 +981,13 @@ function generateVariableFile(spec, vars) {
466
981
  lines.push("import { wireVariable } from '@pikku/core/variable'");
467
982
  lines.push('');
468
983
  const schemaVarName = `${camelName}BaseUrlSchema`;
469
- const urlsLiteral = serverUrls.map((u) => JSON.stringify(u)).join(', ');
470
- lines.push(`export const ${schemaVarName} = z.enum([${urlsLiteral}]).default(${JSON.stringify(defaultUrl)})`);
984
+ if (serverUrls.length > 0) {
985
+ const urlsLiteral = serverUrls.map((u) => JSON.stringify(u)).join(', ');
986
+ lines.push(`export const ${schemaVarName} = z.enum([${urlsLiteral}]).default(${JSON.stringify(defaultUrl)})`);
987
+ }
988
+ else {
989
+ lines.push(`export const ${schemaVarName} = z.string().describe('Base URL for the ${displayName} API')`);
990
+ }
471
991
  lines.push('');
472
992
  lines.push(`wireVariable({`);
473
993
  lines.push(` name: '${screamingName}_BASE_URL',`);