@pikku/openapi-parser 0.12.9 → 0.12.11

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/src/codegen.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  schemaVarName,
13
13
  sanitizeTypeName,
14
14
  createContext,
15
+ snakeToCamel,
15
16
  type ZodCodegenContext,
16
17
  } from './zod-codegen.js'
17
18
  import {
@@ -34,6 +35,7 @@ interface CodegenFlags {
34
35
  secret: boolean
35
36
  credential?: 'apikey' | 'bearer' | 'oauth2'
36
37
  mcp?: boolean
38
+ camelCase?: boolean
37
39
  }
38
40
 
39
41
  function safeKey(key: string): string {
@@ -88,6 +90,175 @@ function getErrorClassesForResponses(
88
90
  return classes
89
91
  }
90
92
 
93
+ /**
94
+ * Collect all component schema names directly referenced by an operation's
95
+ * params, request body, and response schema.
96
+ */
97
+ function collectOperationSchemaRefs(
98
+ parsed: ParsedOperation,
99
+ componentNames: Set<string>,
100
+ schemaIdentityMap: Map<object, string>
101
+ ): Set<string> {
102
+ const refs = new Set<string>()
103
+ const allSchemas: any[] = []
104
+
105
+ // Gather all schema objects from this operation
106
+ for (const p of [
107
+ ...parsed.pathParams,
108
+ ...parsed.queryParams,
109
+ ...parsed.headerParams,
110
+ ]) {
111
+ if (p.schema) allSchemas.push(p.schema)
112
+ }
113
+ if (parsed.requestBody) allSchemas.push(parsed.requestBody)
114
+ if (parsed.responseSchema) allSchemas.push(parsed.responseSchema)
115
+
116
+ for (const schema of allSchemas) {
117
+ // Check if the schema itself IS a component schema (by object identity).
118
+ // collectSchemaRefs skips the root identity check, so we must do it here.
119
+ if (schema && typeof schema === 'object') {
120
+ const identityName = schemaIdentityMap.get(schema)
121
+ if (identityName && componentNames.has(identityName)) {
122
+ refs.add(identityName)
123
+ }
124
+ }
125
+ for (const ref of collectSchemaRefs(
126
+ schema,
127
+ componentNames,
128
+ schemaIdentityMap
129
+ )) {
130
+ refs.add(ref)
131
+ }
132
+ }
133
+ return refs
134
+ }
135
+
136
+ /**
137
+ * Compute the transitive closure of schema dependencies.
138
+ * Given a set of schema names, expand it to include all schemas they depend on.
139
+ */
140
+ function transitiveClosure(
141
+ schemaNames: Set<string>,
142
+ allSchemas: Record<string, any>,
143
+ componentNames: Set<string>,
144
+ schemaIdentityMap: Map<object, string>
145
+ ): Set<string> {
146
+ const result = new Set<string>()
147
+ const queue = [...schemaNames]
148
+ while (queue.length > 0) {
149
+ const name = queue.pop()!
150
+ if (result.has(name)) continue
151
+ if (!allSchemas[name]) continue
152
+ result.add(name)
153
+ const deps = collectSchemaRefs(
154
+ allSchemas[name],
155
+ componentNames,
156
+ schemaIdentityMap
157
+ )
158
+ for (const dep of deps) {
159
+ if (!result.has(dep)) queue.push(dep)
160
+ }
161
+ }
162
+ return result
163
+ }
164
+
165
+ interface SchemaPartition {
166
+ /** Schemas referenced by 2+ operations — go in the shared types file */
167
+ shared: Set<string>
168
+ /** Map from schema name to the single operation index that uses it */
169
+ single: Map<string, number>
170
+ /** Schemas not referenced by any operation (directly or transitively) */
171
+ unused: Set<string>
172
+ }
173
+
174
+ /**
175
+ * Partition component schemas into shared, single-use, and unused buckets.
176
+ *
177
+ * Algorithm:
178
+ * 1. For each operation, collect its directly referenced component schemas
179
+ * 2. Expand each operation's refs to include transitive deps
180
+ * 3. Count how many operations reference each schema (via transitive closure)
181
+ * 4. Schemas with 2+ operations → shared, 1 operation → single, 0 → unused
182
+ * 5. Single-use schemas whose transitive deps include shared schemas still go
183
+ * into single-use (the function file will import those shared deps)
184
+ */
185
+ function partitionSchemas(
186
+ spec: ParsedSpec,
187
+ schemaIdentityMap: Map<object, string>
188
+ ): SchemaPartition {
189
+ const componentNames = new Set(Object.keys(spec.componentSchemas))
190
+
191
+ // Step 1: Direct refs per operation
192
+ const opDirectRefs: Set<string>[] = spec.operations.map((op) =>
193
+ collectOperationSchemaRefs(op, componentNames, schemaIdentityMap)
194
+ )
195
+
196
+ // Step 2: Transitive refs per operation
197
+ const opTransitiveRefs: Set<string>[] = opDirectRefs.map((directRefs) =>
198
+ transitiveClosure(
199
+ directRefs,
200
+ spec.componentSchemas,
201
+ componentNames,
202
+ schemaIdentityMap
203
+ )
204
+ )
205
+
206
+ // Step 3: Count how many operations reference each schema
207
+ const refCount = new Map<string, Set<number>>()
208
+ for (let i = 0; i < opTransitiveRefs.length; i++) {
209
+ for (const name of opTransitiveRefs[i]) {
210
+ if (!refCount.has(name)) refCount.set(name, new Set())
211
+ refCount.get(name)!.add(i)
212
+ }
213
+ }
214
+
215
+ // Step 4: Partition
216
+ const shared = new Set<string>()
217
+ const single = new Map<string, number>()
218
+ const unused = new Set<string>()
219
+
220
+ for (const name of componentNames) {
221
+ const ops = refCount.get(name)
222
+ if (!ops || ops.size === 0) {
223
+ unused.add(name)
224
+ } else if (ops.size === 1) {
225
+ single.set(name, [...ops][0])
226
+ } else {
227
+ shared.add(name)
228
+ }
229
+ }
230
+
231
+ // Step 5: If a single-use schema transitively depends on another single-use
232
+ // schema from a DIFFERENT operation, promote both to shared.
233
+ // This handles chains like: OpA → SchemaX → SchemaY ← OpB
234
+ // Also: single-use schemas that depend on other single-use schemas from the
235
+ // same operation stay single-use (they'll be inlined together).
236
+ let changed = true
237
+ while (changed) {
238
+ changed = false
239
+ for (const [name, opIdx] of [...single]) {
240
+ const deps = collectSchemaRefs(
241
+ spec.componentSchemas[name],
242
+ componentNames,
243
+ schemaIdentityMap
244
+ )
245
+ for (const dep of deps) {
246
+ if (single.has(dep) && single.get(dep) !== opIdx) {
247
+ // Dep is single-use but for a different operation — promote both
248
+ shared.add(dep)
249
+ single.delete(dep)
250
+ shared.add(name)
251
+ single.delete(name)
252
+ changed = true
253
+ break
254
+ }
255
+ }
256
+ }
257
+ }
258
+
259
+ return { shared, single, unused }
260
+ }
261
+
91
262
  export function generateAddonFromOpenAPI(
92
263
  spec: ParsedSpec,
93
264
  vars: AddonVars,
@@ -101,7 +272,52 @@ export function generateAddonFromOpenAPI(
101
272
  for (const schemaName of Object.keys(spec.componentSchemas)) {
102
273
  schemaRefs.set(schemaName, schemaVarName(schemaName))
103
274
  }
104
- const ctx = createContext(schemaRefs)
275
+
276
+ // Build an identity map from resolved schema objects to their component names.
277
+ // After $ref resolution, many references become the same JS object as the
278
+ // component schema (or a spread-copy). This map lets the codegen detect those
279
+ // resolved references by object identity instead of relying on $ref strings.
280
+ const schemaIdentityMap = new Map<object, string>()
281
+ for (const [schemaName, schema] of Object.entries(spec.componentSchemas)) {
282
+ if (schema && typeof schema === 'object') {
283
+ schemaIdentityMap.set(schema, schemaName)
284
+ }
285
+ }
286
+
287
+ // Partition schemas: shared (2+ ops), single-use (1 op), unused (0 ops)
288
+ const partition = partitionSchemas(spec, schemaIdentityMap)
289
+
290
+ // Build the set of schemas that go in the shared types file:
291
+ // shared schemas + their transitive dependencies
292
+ const componentNames = new Set(Object.keys(spec.componentSchemas))
293
+ const sharedWithDeps = transitiveClosure(
294
+ partition.shared,
295
+ spec.componentSchemas,
296
+ componentNames,
297
+ schemaIdentityMap
298
+ )
299
+ // Any transitive dep of a shared schema that was single-use gets promoted to shared
300
+ for (const name of sharedWithDeps) {
301
+ partition.single.delete(name)
302
+ }
303
+
304
+ // Build a reduced componentSchemas for the types file (only shared schemas)
305
+ const sharedComponentSchemas: Record<string, any> = {}
306
+ for (const schemaName of sharedWithDeps) {
307
+ if (spec.componentSchemas[schemaName]) {
308
+ sharedComponentSchemas[schemaName] = spec.componentSchemas[schemaName]
309
+ }
310
+ }
311
+
312
+ // schemaRefs for shared types file context — only shared schemas
313
+ const sharedSchemaRefs = new Map<string, string>()
314
+ for (const schemaName of sharedWithDeps) {
315
+ sharedSchemaRefs.set(schemaName, schemaVarName(schemaName))
316
+ }
317
+
318
+ const ctx = createContext(sharedSchemaRefs, schemaIdentityMap, {
319
+ camelCase: flags.camelCase,
320
+ })
105
321
 
106
322
  // Generate operation names
107
323
  const paths = spec.operations.map((op) => op.path)
@@ -120,21 +336,54 @@ export function generateAddonFromOpenAPI(
120
336
  namedOps.map((named, i) => ({ named, parsed: spec.operations[i] }))
121
337
 
122
338
  // Generate types file with shared schemas (only if there are any)
123
- if (Object.keys(spec.componentSchemas).length > 0) {
124
- files[`src/${name}.types.ts`] = generateTypesFile(spec, ctx)
339
+ if (Object.keys(sharedComponentSchemas).length > 0) {
340
+ files[`src/${name}.types.ts`] = generateTypesFile(
341
+ { ...spec, componentSchemas: sharedComponentSchemas },
342
+ ctx,
343
+ schemaIdentityMap
344
+ )
345
+ }
346
+
347
+ // Collect single-use schemas per operation index
348
+ const singleSchemasPerOp = new Map<number, Set<string>>()
349
+ for (const [schemaName, opIdx] of partition.single) {
350
+ if (!singleSchemasPerOp.has(opIdx)) singleSchemasPerOp.set(opIdx, new Set())
351
+ singleSchemasPerOp.get(opIdx)!.add(schemaName)
352
+ }
353
+ // Expand single-use schemas to include their transitive deps that are also single-use
354
+ for (const [_opIdx, schemas] of singleSchemasPerOp) {
355
+ const expanded = transitiveClosure(
356
+ schemas,
357
+ spec.componentSchemas,
358
+ componentNames,
359
+ schemaIdentityMap
360
+ )
361
+ // Only keep schemas that are NOT in the shared set
362
+ for (const s of expanded) {
363
+ if (!sharedWithDeps.has(s) && !partition.unused.has(s)) {
364
+ schemas.add(s)
365
+ }
366
+ }
125
367
  }
126
368
 
127
369
  // Generate function files
128
370
  const functionExports: string[] = []
129
- for (const { named, parsed } of opPairs) {
130
- const funcCtx = createContext(schemaRefs)
371
+ for (let i = 0; i < opPairs.length; i++) {
372
+ const { named, parsed } = opPairs[i]
373
+ const funcCtx = createContext(schemaRefs, schemaIdentityMap, {
374
+ camelCase: flags.camelCase,
375
+ })
376
+ const inlineSchemas = singleSchemasPerOp.get(i) ?? new Set<string>()
131
377
  const funcCode = generateFunctionFile(
132
378
  named,
133
379
  parsed,
134
380
  vars,
135
381
  funcCtx,
136
382
  spec,
137
- flags
383
+ flags,
384
+ inlineSchemas,
385
+ sharedWithDeps,
386
+ schemaIdentityMap
138
387
  )
139
388
  files[`src/functions/${named.functionName}.function.ts`] = funcCode
140
389
  functionExports.push(named.functionName)
@@ -157,20 +406,191 @@ export function generateAddonFromOpenAPI(
157
406
  return files
158
407
  }
159
408
 
160
- function generateTypesFile(spec: ParsedSpec, ctx: ZodCodegenContext): string {
409
+ /**
410
+ * Collect all component schema names referenced (directly or via properties/items/allOf/oneOf/anyOf)
411
+ * by a given OpenAPI schema object.
412
+ *
413
+ * After $ref resolution, most $ref strings are gone and replaced by the resolved
414
+ * object (often the same JS identity as the component schema). We detect
415
+ * dependencies both by surviving $ref strings AND by object identity via
416
+ * schemaIdentityMap.
417
+ */
418
+ function collectSchemaRefs(
419
+ schema: any,
420
+ componentNames: Set<string>,
421
+ schemaIdentityMap: Map<object, string>
422
+ ): Set<string> {
423
+ const refs = new Set<string>()
424
+ const visited = new Set<any>()
425
+
426
+ function walk(node: any, isRoot: boolean) {
427
+ if (!node || typeof node !== 'object' || visited.has(node)) return
428
+ visited.add(node)
429
+
430
+ // Check surviving $ref strings
431
+ if (node.$ref) {
432
+ const refName = node.$ref.split('/').pop()!
433
+ if (componentNames.has(refName)) refs.add(refName)
434
+ }
435
+
436
+ // Check if this node IS a known component schema (resolved $ref by identity).
437
+ // Skip this check for the root node (the schema we're analyzing),
438
+ // otherwise we'd match the schema against itself and return early,
439
+ // missing its internal dependencies.
440
+ if (!isRoot) {
441
+ const identityName = schemaIdentityMap.get(node)
442
+ if (identityName && componentNames.has(identityName)) {
443
+ refs.add(identityName)
444
+ // Don't walk into the component schema's internals — those are
445
+ // handled when that schema is processed as a top-level entry.
446
+ return
447
+ }
448
+ }
449
+
450
+ if (node.properties) {
451
+ for (const prop of Object.values(node.properties)) walk(prop, false)
452
+ }
453
+ if (node.items) walk(node.items, false)
454
+ if (node.allOf) for (const s of node.allOf) walk(s, false)
455
+ if (node.oneOf) for (const s of node.oneOf) walk(s, false)
456
+ if (node.anyOf) for (const s of node.anyOf) walk(s, false)
457
+ if (
458
+ node.additionalProperties &&
459
+ typeof node.additionalProperties === 'object'
460
+ ) {
461
+ walk(node.additionalProperties, false)
462
+ }
463
+ }
464
+
465
+ walk(schema, true)
466
+ return refs
467
+ }
468
+
469
+ /**
470
+ * Topologically sort component schemas so that dependencies come before dependents.
471
+ * Schemas involved in cycles are placed in arbitrary order (cycles are handled via z.lazy).
472
+ */
473
+ function topoSortSchemas(
474
+ schemas: Record<string, any>,
475
+ schemaIdentityMap: Map<object, string>
476
+ ): { sorted: string[]; cyclicEdges: Set<string> } {
477
+ const names = new Set(Object.keys(schemas))
478
+ const deps = new Map<string, Set<string>>()
479
+
480
+ const selfRefs = new Set<string>()
481
+ for (const [name, schema] of Object.entries(schemas)) {
482
+ const refNames = collectSchemaRefs(schema, names, schemaIdentityMap)
483
+ if (refNames.has(name)) {
484
+ selfRefs.add(name) // track self-referential schemas
485
+ }
486
+ refNames.delete(name) // remove self-refs from dependency graph
487
+ deps.set(name, refNames)
488
+ }
489
+
490
+ const sorted: string[] = []
491
+ const visited = new Set<string>()
492
+ const inStack = new Set<string>()
493
+ const cyclicEdges = new Set<string>() // "child" schemas that are referenced before declaration
494
+
495
+ function visit(name: string) {
496
+ if (visited.has(name)) return
497
+ if (inStack.has(name)) {
498
+ // This is a cycle — mark this name as needing z.lazy()
499
+ cyclicEdges.add(name)
500
+ return
501
+ }
502
+ inStack.add(name)
503
+ const children = deps.get(name) ?? new Set()
504
+ for (const child of children) {
505
+ if (names.has(child)) visit(child)
506
+ }
507
+ inStack.delete(name)
508
+ visited.add(name)
509
+ sorted.push(name)
510
+ }
511
+
512
+ for (const name of names) {
513
+ visit(name)
514
+ }
515
+
516
+ // Self-referential schemas also need z.lazy() forward declarations
517
+ for (const name of selfRefs) {
518
+ cyclicEdges.add(name)
519
+ }
520
+ return { sorted, cyclicEdges }
521
+ }
522
+
523
+ function generateTypesFile(
524
+ spec: ParsedSpec,
525
+ ctx: ZodCodegenContext,
526
+ schemaIdentityMap: Map<object, string>
527
+ ): string {
161
528
  const lines: string[] = []
162
529
  lines.push("import { z } from 'zod'")
163
530
  lines.push('')
164
531
  lines.push(`// Shared schemas from ${spec.info.title} v${spec.info.version}`)
165
532
  lines.push('')
166
533
 
167
- for (const [name, schema] of Object.entries(spec.componentSchemas)) {
168
- const varName = schemaVarName(name)
534
+ const { sorted, cyclicEdges } = topoSortSchemas(
535
+ spec.componentSchemas,
536
+ schemaIdentityMap
537
+ )
538
+
539
+ // Track emitted variable names and type names to deduplicate collisions
540
+ // (e.g. two OpenAPI components that sanitize to the same JS identifier)
541
+ const emittedVarNames = new Set<string>()
542
+ const emittedTypeNames = new Set<string>()
543
+
544
+ function deduplicateName(baseName: string, usedSet: Set<string>): string {
545
+ if (!usedSet.has(baseName)) {
546
+ usedSet.add(baseName)
547
+ return baseName
548
+ }
549
+ let counter = 2
550
+ while (usedSet.has(`${baseName}_${counter}`)) {
551
+ counter++
552
+ }
553
+ const deduped = `${baseName}_${counter}`
554
+ usedSet.add(deduped)
555
+ return deduped
556
+ }
557
+
558
+ // For schemas that participate in cycles, emit a z.lazy() forward declaration
559
+ // before any schemas are defined, so forward references work.
560
+ if (cyclicEdges.size > 0) {
561
+ lines.push('// Forward declarations for circular references')
562
+ for (const name of cyclicEdges) {
563
+ const varName = deduplicateName(schemaVarName(name), emittedVarNames)
564
+ const typeName = deduplicateName(sanitizeTypeName(name), emittedTypeNames)
565
+ lines.push(
566
+ `export const ${varName}: z.ZodType<any> = z.lazy(() => _${varName})`
567
+ )
568
+ lines.push(`export type ${typeName} = z.infer<typeof ${varName}>`)
569
+ lines.push('')
570
+ }
571
+ }
572
+
573
+ // Emit schemas in topological order
574
+ for (const name of sorted) {
575
+ const schema = spec.componentSchemas[name]
576
+ const varName = deduplicateName(schemaVarName(name), emittedVarNames)
577
+ const typeName = deduplicateName(sanitizeTypeName(name), emittedTypeNames)
578
+ // Temporarily remove the current schema from the identity map so that
579
+ // schemaToZod doesn't short-circuit to referencing the variable we're
580
+ // currently defining (which would produce `const XSchema = XSchema`).
581
+ ctx.schemaIdentityMap.delete(schema)
169
582
  const zodCode = schemaToZod(schema, ctx)
170
- lines.push(`export const ${varName} = ${zodCode}`)
171
- lines.push(
172
- `export type ${sanitizeTypeName(name)} = z.infer<typeof ${varName}>`
173
- )
583
+ // Restore it for subsequent schemas that may reference this one.
584
+ ctx.schemaIdentityMap.set(schema, name)
585
+
586
+ if (cyclicEdges.has(name)) {
587
+ // The public variable was already declared above via z.lazy();
588
+ // emit the real implementation with an underscore prefix.
589
+ lines.push(`const _${varName} = ${zodCode}`)
590
+ } else {
591
+ lines.push(`export const ${varName} = ${zodCode}`)
592
+ lines.push(`export type ${typeName} = z.infer<typeof ${varName}>`)
593
+ }
174
594
  lines.push('')
175
595
  }
176
596
 
@@ -183,20 +603,13 @@ function generateFunctionFile(
183
603
  vars: AddonVars,
184
604
  ctx: ZodCodegenContext,
185
605
  spec: ParsedSpec,
186
- flags: CodegenFlags
606
+ flags: CodegenFlags,
607
+ inlineSchemas: Set<string> = new Set(),
608
+ sharedSchemas: Set<string> = new Set(),
609
+ schemaIdentityMap: Map<object, string> = new Map()
187
610
  ): string {
188
611
  const lines: string[] = []
189
- const { camelName } = vars
190
-
191
- // Tag description as file header
192
- if (parsed.tags.length > 0) {
193
- const tag = parsed.tags[0]
194
- const tagDesc = spec.tagDescriptions[tag]
195
- if (tagDesc) {
196
- lines.push(`// ${tag} — ${tagDesc}`)
197
- lines.push('')
198
- }
199
- }
612
+ const { camelName, name } = vars
200
613
 
201
614
  const hasInput =
202
615
  parsed.pathParams.length > 0 ||
@@ -209,10 +622,44 @@ function generateFunctionFile(
209
622
  const inputName = `${pascalName}Input`
210
623
  const outputName = `${pascalName}Output`
211
624
 
625
+ // Pre-generate Zod code to discover which schema refs are used
626
+ let inputCode: string | undefined
627
+ let outputCode: string | undefined
628
+ if (hasInput) {
629
+ inputCode = buildInputSchema(parsed, ctx)
630
+ }
631
+ if (parsed.responseSchema) {
632
+ outputCode = buildOutputSchema(parsed.responseSchema, ctx)
633
+ }
634
+
635
+ // Bug 2: If generated Zod code is too large (> 500 lines), TypeScript
636
+ // can't infer the type (TS7056). Replace with z.any() to stay compilable.
637
+ const LINE_LIMIT = 500
638
+ if (inputCode && inputCode.split('\n').length > LINE_LIMIT) {
639
+ inputCode = 'z.any()'
640
+ }
641
+ if (outputCode && outputCode.split('\n').length > LINE_LIMIT) {
642
+ outputCode = 'z.any()'
643
+ }
644
+
645
+ // Tag description as file header
646
+ if (parsed.tags.length > 0) {
647
+ const tag = parsed.tags[0]
648
+ const tagDesc = spec.tagDescriptions[tag]
649
+ if (tagDesc) {
650
+ const cleanDesc = tagDesc
651
+ .replace(/\r?\n/g, ' ')
652
+ .replace(/\s+/g, ' ')
653
+ .trim()
654
+ lines.push(`// ${tag} — ${cleanDesc}`)
655
+ lines.push('')
656
+ }
657
+ }
658
+
212
659
  // Determine error imports needed
213
660
  const errorClasses = getErrorClassesForResponses(parsed.errorResponses)
214
661
 
215
- const needsZod = hasInput || !!parsed.responseSchema
662
+ const needsZod = hasInput || !!parsed.responseSchema || inlineSchemas.size > 0
216
663
  if (needsZod) {
217
664
  lines.push("import { z } from 'zod'")
218
665
  }
@@ -224,18 +671,144 @@ function generateFunctionFile(
224
671
  )
225
672
  }
226
673
 
674
+ // Import referenced component schemas from the types file.
675
+ // Only import schemas that are in the shared set (not inlined ones).
676
+ // In addition to the refs tracked through usedRefs, scan the generated Zod
677
+ // code for schema variable names that may have been missed (e.g. when
678
+ // a $ref survived resolution in a nested path not covered by usedRefs).
679
+ if (Object.keys(spec.componentSchemas).length > 0) {
680
+ const allGeneratedCode = [inputCode, outputCode].filter(Boolean).join('\n')
681
+ for (const [refName, varName] of ctx.schemaRefs) {
682
+ if (!ctx.usedRefs.has(refName) && allGeneratedCode.includes(varName)) {
683
+ ctx.usedRefs.add(refName)
684
+ }
685
+ }
686
+
687
+ const schemaImports = [
688
+ ...new Set(
689
+ [...ctx.usedRefs]
690
+ .filter((refName) => sharedSchemas.has(refName)) // only import shared schemas
691
+ .map((refName) => ctx.schemaRefs.get(refName))
692
+ .filter(Boolean)
693
+ ),
694
+ ].sort()
695
+ if (schemaImports.length > 0) {
696
+ lines.push(
697
+ `import { ${schemaImports.join(', ')} } from '../${name}.types.js'`
698
+ )
699
+ }
700
+ }
701
+
227
702
  lines.push('')
228
703
 
704
+ // Emit inline (single-use) schema definitions before input/output
705
+ if (inlineSchemas.size > 0) {
706
+ // Build a mini component schemas map for just the inline schemas
707
+ const inlineComponentSchemas: Record<string, any> = {}
708
+ for (const schemaName of inlineSchemas) {
709
+ if (spec.componentSchemas[schemaName]) {
710
+ inlineComponentSchemas[schemaName] = spec.componentSchemas[schemaName]
711
+ }
712
+ }
713
+
714
+ if (Object.keys(inlineComponentSchemas).length > 0) {
715
+ // Create a context that knows about both shared (for imports) and inline schemas
716
+ const inlineCtx = createContext(ctx.schemaRefs, schemaIdentityMap, {
717
+ camelCase: flags.camelCase,
718
+ })
719
+
720
+ const { sorted, cyclicEdges } = topoSortSchemas(
721
+ inlineComponentSchemas,
722
+ schemaIdentityMap
723
+ )
724
+
725
+ // Bug 4: Pre-generate all Zod code and detect forward references.
726
+ // The topological sort may miss dependencies when $ref resolution
727
+ // replaces references with copies (breaking object identity).
728
+ // Scan generated code for variable names that appear before their
729
+ // declaration and promote those to z.lazy() forward declarations.
730
+ const inlineSorted = sorted.filter((s) => inlineSchemas.has(s))
731
+ const generatedCode = new Map<string, string>()
732
+ for (const sName of inlineSorted) {
733
+ const schema = spec.componentSchemas[sName]
734
+ inlineCtx.schemaIdentityMap.delete(schema)
735
+ const zodCode = schemaToZod(schema, inlineCtx)
736
+ inlineCtx.schemaIdentityMap.set(schema, sName)
737
+ generatedCode.set(sName, zodCode)
738
+ }
739
+
740
+ // Build a map of variable names to schema names for inline schemas
741
+ const inlineVarToName = new Map<string, string>()
742
+ for (const sName of inlineSorted) {
743
+ inlineVarToName.set(schemaVarName(sName), sName)
744
+ }
745
+
746
+ // Detect forward references and self-references: for each schema in order,
747
+ // check if its generated code references a variable that appears later in
748
+ // the sorted list OR references its own variable (self-referential schema).
749
+ const forwardRefs = new Set<string>(cyclicEdges)
750
+ const emittedSoFar = new Set<string>()
751
+ for (const sName of inlineSorted) {
752
+ const code = generatedCode.get(sName)!
753
+ const ownVarN = schemaVarName(sName)
754
+ // Self-reference: schema uses its own variable name in its definition
755
+ if (code.includes(ownVarN)) {
756
+ forwardRefs.add(sName)
757
+ }
758
+ for (const [varN, refName] of inlineVarToName) {
759
+ if (
760
+ refName !== sName &&
761
+ !emittedSoFar.has(refName) &&
762
+ code.includes(varN)
763
+ ) {
764
+ // This schema references another inline schema not yet emitted
765
+ forwardRefs.add(refName)
766
+ }
767
+ }
768
+ emittedSoFar.add(sName)
769
+ }
770
+
771
+ const emittedVarNames = new Set<string>()
772
+ const emittedTypeNames = new Set<string>()
773
+
774
+ // Forward declarations for cycles and forward references within inline schemas
775
+ if (forwardRefs.size > 0) {
776
+ lines.push('// Forward declarations for circular references')
777
+ for (const sName of forwardRefs) {
778
+ if (!inlineSchemas.has(sName)) continue
779
+ const varN = schemaVarName(sName)
780
+ const typeN = sanitizeTypeName(sName)
781
+ emittedVarNames.add(varN)
782
+ emittedTypeNames.add(typeN)
783
+ lines.push(`const ${varN}: z.ZodType<any> = z.lazy(() => _${varN})`)
784
+ lines.push('')
785
+ }
786
+ }
787
+
788
+ for (const sName of inlineSorted) {
789
+ const varN = schemaVarName(sName)
790
+ emittedVarNames.add(varN)
791
+
792
+ const zodCode = generatedCode.get(sName)!
793
+
794
+ if (forwardRefs.has(sName)) {
795
+ lines.push(`const _${varN} = ${zodCode}`)
796
+ } else {
797
+ lines.push(`const ${varN} = ${zodCode}`)
798
+ }
799
+ lines.push('')
800
+ }
801
+ }
802
+ }
803
+
229
804
  // Build Input schema (exported for pikku schema discovery)
230
- if (hasInput) {
231
- const inputCode = buildInputSchema(parsed, ctx)
805
+ if (hasInput && inputCode) {
232
806
  lines.push(`export const ${inputName} = ${inputCode}`)
233
807
  lines.push('')
234
808
  }
235
809
 
236
810
  // Build Output schema (exported for pikku schema discovery)
237
- if (parsed.responseSchema) {
238
- const outputCode = buildOutputSchema(parsed.responseSchema, ctx)
811
+ if (parsed.responseSchema && outputCode) {
239
812
  lines.push(`export const ${outputName} = ${outputCode}`)
240
813
  lines.push('')
241
814
  }
@@ -260,12 +833,16 @@ function generateFunctionFile(
260
833
  funcConfig.push(' mcp: true,')
261
834
  }
262
835
 
263
- const funcParams = hasInput ? `{ ${camelName} }, data` : `{ ${camelName} }`
836
+ // Bug 3: Avoid duplicate identifier when camelName is 'data' (TS2300)
837
+ const inputParamName = camelName === 'data' ? 'inputData' : 'data'
838
+ const funcParams = hasInput
839
+ ? `{ ${camelName} }, ${inputParamName}`
840
+ : `{ ${camelName} }`
264
841
 
265
842
  const returnCast = parsed.responseSchema ? ' as any' : ''
266
843
  funcConfig.push(
267
844
  ` func: async (${funcParams}) => {`,
268
- ` return ${camelName}.call(${JSON.stringify(method)}, ${JSON.stringify(parsed.path)}${hasInput ? ', data' : ''})${returnCast}`,
845
+ ` return ${camelName}.call(${JSON.stringify(method)}, ${JSON.stringify(parsed.path)}${hasInput ? `, ${inputParamName}` : ''})${returnCast}`,
269
846
  ' },'
270
847
  )
271
848
 
@@ -282,42 +859,64 @@ function buildInputSchema(
282
859
  ctx: ZodCodegenContext
283
860
  ): string {
284
861
  const props: string[] = []
862
+ // Track which parameter names have been emitted to prevent duplicates.
863
+ // When shared path-level params and operation-level params overlap
864
+ // (e.g. both define "Version"), the later (operation-level) one wins.
865
+ const emittedNames = new Set<string>()
866
+
867
+ // Deduplicate params: operation-level params override shared path-level
868
+ // params with the same name. Build deduplicated lists per category.
869
+ function deduplicateParams(params: typeof parsed.pathParams) {
870
+ const seen = new Map<string, (typeof params)[0]>()
871
+ for (const param of params) {
872
+ seen.set(param.name, param) // last wins (operation-level comes after shared)
873
+ }
874
+ return [...seen.values()]
875
+ }
285
876
 
286
- for (const param of parsed.pathParams) {
287
- props.push(formatParamProp(param, ctx))
877
+ const useCamel = !!ctx.camelCase
878
+
879
+ for (const param of deduplicateParams(parsed.pathParams)) {
880
+ if (!emittedNames.has(param.name)) {
881
+ emittedNames.add(param.name)
882
+ props.push(formatParamProp(param, ctx, useCamel))
883
+ }
288
884
  }
289
885
 
290
- for (const param of parsed.queryParams) {
291
- props.push(formatParamProp(param, ctx))
886
+ for (const param of deduplicateParams(parsed.queryParams)) {
887
+ if (!emittedNames.has(param.name)) {
888
+ emittedNames.add(param.name)
889
+ props.push(formatParamProp(param, ctx, useCamel))
890
+ }
292
891
  }
293
892
 
294
- for (const param of parsed.headerParams) {
295
- props.push(formatParamProp(param, ctx))
893
+ for (const param of deduplicateParams(parsed.headerParams)) {
894
+ if (!emittedNames.has(param.name)) {
895
+ emittedNames.add(param.name)
896
+ props.push(formatParamProp(param, ctx, useCamel))
897
+ }
296
898
  }
297
899
 
298
900
  if (parsed.requestBody) {
299
901
  if (parsed.requestBody.properties) {
300
- const paramNames = new Set([
301
- ...parsed.pathParams.map((p) => p.name),
302
- ...parsed.queryParams.map((p) => p.name),
303
- ...parsed.headerParams.map((p) => p.name),
304
- ])
305
902
  const requiredSet = new Set(parsed.requestBody.required ?? [])
306
903
  for (const [key, propSchema] of Object.entries(
307
904
  parsed.requestBody.properties
308
905
  )) {
309
906
  // Skip readOnly properties from input
310
907
  if (propSchema.readOnly) continue
311
- // Skip body properties that collide with path/query/header params
312
- if (paramNames.has(key)) {
908
+ // Skip body properties that collide with already-emitted params
909
+ if (emittedNames.has(key)) {
313
910
  console.warn(
314
911
  `[openapi] Skipping body property '${key}' — collides with path/query/header param`
315
912
  )
316
913
  continue
317
914
  }
915
+ emittedNames.add(key)
318
916
  const isOptional = !requiredSet.has(key)
319
917
  const zodCode = schemaToZod(propSchema, ctx, { optional: isOptional })
320
- props.push(` ${safeKey(key)}: ${zodCode},`)
918
+ const outputKey = useCamel ? snakeToCamel(key) : key
919
+ props.push(` ${safeKey(outputKey)}: ${zodCode},`)
321
920
  }
322
921
  } else {
323
922
  const bodyZod = schemaToZod(parsed.requestBody, ctx)
@@ -336,12 +935,14 @@ function formatParamProp(
336
935
  description?: string
337
936
  example?: unknown
338
937
  },
339
- ctx: ZodCodegenContext
938
+ ctx: ZodCodegenContext,
939
+ camelCase = false
340
940
  ): string {
341
941
  const zodCode = schemaToZod(param.schema, ctx, { optional: !param.required })
342
942
 
343
943
  let descParts: string[] = []
344
- if (param.description) descParts.push(param.description)
944
+ if (param.description)
945
+ descParts.push(param.description.replace(/\*\//g, '* /'))
345
946
  if (param.example !== undefined)
346
947
  descParts.push(`Example: ${JSON.stringify(param.example)}`)
347
948
 
@@ -350,23 +951,31 @@ function formatParamProp(
350
951
  ? `${zodCode}.describe(${JSON.stringify(descParts.join('. '))})`
351
952
  : zodCode
352
953
 
353
- return ` ${safeKey(param.name)}: ${desc},`
954
+ const outputKey = camelCase ? snakeToCamel(param.name) : param.name
955
+ return ` ${safeKey(outputKey)}: ${desc},`
354
956
  }
355
957
 
356
958
  function buildOutputSchema(schema: any, ctx: ZodCodegenContext): string {
357
- // For output schemas, filter out writeOnly properties
959
+ // For output schemas, filter out writeOnly properties.
960
+ // Only create a filtered copy if there are actually writeOnly properties,
961
+ // to preserve object identity for schema identity map matching.
358
962
  if (schema.properties) {
359
- const filteredProps: Record<string, any> = {}
360
- for (const [key, propSchema] of Object.entries(schema.properties) as [
361
- string,
362
- any,
363
- ][]) {
364
- if (!propSchema.writeOnly) {
365
- filteredProps[key] = propSchema
963
+ const hasWriteOnly = Object.values(schema.properties).some(
964
+ (p: any) => p.writeOnly
965
+ )
966
+ if (hasWriteOnly) {
967
+ const filteredProps: Record<string, any> = {}
968
+ for (const [key, propSchema] of Object.entries(schema.properties) as [
969
+ string,
970
+ any,
971
+ ][]) {
972
+ if (!propSchema.writeOnly) {
973
+ filteredProps[key] = propSchema
974
+ }
366
975
  }
976
+ const filteredSchema = { ...schema, properties: filteredProps }
977
+ return schemaToZod(filteredSchema, ctx)
367
978
  }
368
- const filteredSchema = { ...schema, properties: filteredProps }
369
- return schemaToZod(filteredSchema, ctx)
370
979
  }
371
980
  return schemaToZod(schema, ctx)
372
981
  }
@@ -393,7 +1002,8 @@ function generateServiceFile(
393
1002
  vars: AddonVars,
394
1003
  flags: CodegenFlags
395
1004
  ): string {
396
- const { name, pascalName, screamingName, displayName } = vars
1005
+ const { name, pascalName, screamingName } = vars
1006
+ const displayName = vars.displayName.replace(/'/g, '')
397
1007
  const lines: string[] = []
398
1008
 
399
1009
  // Always import all error classes used in the switch statement
@@ -469,6 +1079,29 @@ function generateServiceFile(
469
1079
  )
470
1080
  lines.push('')
471
1081
 
1082
+ if (flags.camelCase) {
1083
+ lines.push(
1084
+ 'function _toSnakeCase(data: Record<string, unknown>): Record<string, unknown> {'
1085
+ )
1086
+ lines.push(' return Object.fromEntries(')
1087
+ lines.push(
1088
+ ' Object.entries(data).map(([k, v]) => [k.replace(/[A-Z]/g, c => `_${c.toLowerCase()}`), v])'
1089
+ )
1090
+ lines.push(' )')
1091
+ lines.push('}')
1092
+ lines.push('')
1093
+ lines.push(
1094
+ 'function _toCamelCase(data: Record<string, unknown>): Record<string, unknown> {'
1095
+ )
1096
+ lines.push(' return Object.fromEntries(')
1097
+ lines.push(
1098
+ ' Object.entries(data).map(([k, v]) => [k.replace(/[-_]+(.)/g, (_, c) => c.toUpperCase()), v])'
1099
+ )
1100
+ lines.push(' )')
1101
+ lines.push('}')
1102
+ lines.push('')
1103
+ }
1104
+
472
1105
  // Class declaration
473
1106
  lines.push(`export class ${pascalName}Service {`)
474
1107
  lines.push(' private baseUrl: string')
@@ -524,6 +1157,9 @@ function generateServiceFile(
524
1157
  lines.push(' path: string,')
525
1158
  lines.push(' data?: Record<string, unknown>')
526
1159
  lines.push(' ): Promise<T> {')
1160
+ if (flags.camelCase) {
1161
+ lines.push(' const rawData = data ? _toSnakeCase(data) : data')
1162
+ }
527
1163
  lines.push(' const route = ROUTES[`${method} ${path}`]')
528
1164
  lines.push(' let endpoint = path')
529
1165
  lines.push(' let body: Record<string, unknown> | undefined')
@@ -532,25 +1168,27 @@ function generateServiceFile(
532
1168
  lines.push(" 'Content-Type': 'application/json',")
533
1169
  lines.push(' }')
534
1170
  lines.push('')
535
- lines.push(' if (data && route) {')
1171
+ // When camelCase flag is on, use rawData (snake_case converted) for route matching
1172
+ const dataVar = flags.camelCase ? 'rawData' : 'data'
1173
+ lines.push(` if (${dataVar} && route) {`)
536
1174
  lines.push(' // Interpolate path params')
537
1175
  lines.push(' for (const param of route.path) {')
538
- lines.push(' if (data[param] !== undefined) {')
1176
+ lines.push(` if (${dataVar}[param] !== undefined) {`)
539
1177
  lines.push(
540
- ' endpoint = endpoint.replace(`{${param}}`, String(data[param]))'
1178
+ ` endpoint = endpoint.replace(\`{\${param}}\`, String(${dataVar}[param]))`
541
1179
  )
542
1180
  lines.push(' }')
543
1181
  lines.push(' }')
544
1182
  lines.push(' // Extract query params')
545
1183
  lines.push(' for (const param of route.query) {')
546
- lines.push(' if (data[param] !== undefined) {')
547
- lines.push(' query[param] = String(data[param])')
1184
+ lines.push(` if (${dataVar}[param] !== undefined) {`)
1185
+ lines.push(` query[param] = String(${dataVar}[param])`)
548
1186
  lines.push(' }')
549
1187
  lines.push(' }')
550
1188
  lines.push(' // Extract header params')
551
1189
  lines.push(' for (const param of route.headers) {')
552
- lines.push(' if (data[param] !== undefined) {')
553
- lines.push(' headers[param] = String(data[param])')
1190
+ lines.push(` if (${dataVar}[param] !== undefined) {`)
1191
+ lines.push(` headers[param] = String(${dataVar}[param])`)
554
1192
  lines.push(' }')
555
1193
  lines.push(' }')
556
1194
  lines.push(' // Everything else goes into body')
@@ -559,7 +1197,7 @@ function generateServiceFile(
559
1197
  )
560
1198
  lines.push(' const remaining = Object.fromEntries(')
561
1199
  lines.push(
562
- ' Object.entries(data).filter(([k]) => !pathQueryHeaders.has(k))'
1200
+ ` Object.entries(${dataVar}).filter(([k]) => !pathQueryHeaders.has(k))`
563
1201
  )
564
1202
  lines.push(' )')
565
1203
  lines.push(' if (Object.keys(remaining).length > 0) {')
@@ -656,7 +1294,14 @@ function generateServiceFile(
656
1294
  lines.push('')
657
1295
  lines.push(' const text = await response.text()')
658
1296
  lines.push(' if (!text) return {} as T')
659
- lines.push(' return JSON.parse(text) as T')
1297
+ if (flags.camelCase) {
1298
+ lines.push(' const result = JSON.parse(text)')
1299
+ lines.push(
1300
+ ' return (typeof result === "object" && result !== null && !Array.isArray(result) ? _toCamelCase(result) : result) as T'
1301
+ )
1302
+ } else {
1303
+ lines.push(' return JSON.parse(text) as T')
1304
+ }
660
1305
  lines.push(' }')
661
1306
 
662
1307
  lines.push('}')
@@ -666,7 +1311,8 @@ function generateServiceFile(
666
1311
  }
667
1312
 
668
1313
  function generateVariableFile(spec: ParsedSpec, vars: AddonVars): string {
669
- const { camelName, screamingName, displayName } = vars
1314
+ const { camelName, screamingName } = vars
1315
+ const displayName = vars.displayName.replace(/'/g, '')
670
1316
  const serverUrls = spec.serverUrls.length > 0 ? spec.serverUrls : []
671
1317
  const defaultUrl = serverUrls[0]
672
1318
 
@@ -677,10 +1323,16 @@ function generateVariableFile(spec: ParsedSpec, vars: AddonVars): string {
677
1323
 
678
1324
  const schemaVarName = `${camelName}BaseUrlSchema`
679
1325
 
680
- const urlsLiteral = serverUrls.map((u) => JSON.stringify(u)).join(', ')
681
- lines.push(
682
- `export const ${schemaVarName} = z.enum([${urlsLiteral}]).default(${JSON.stringify(defaultUrl)})`
683
- )
1326
+ if (serverUrls.length > 0) {
1327
+ const urlsLiteral = serverUrls.map((u) => JSON.stringify(u)).join(', ')
1328
+ lines.push(
1329
+ `export const ${schemaVarName} = z.enum([${urlsLiteral}]).default(${JSON.stringify(defaultUrl)})`
1330
+ )
1331
+ } else {
1332
+ lines.push(
1333
+ `export const ${schemaVarName} = z.string().describe('Base URL for the ${displayName} API')`
1334
+ )
1335
+ }
684
1336
 
685
1337
  lines.push('')
686
1338
  lines.push(`wireVariable({`)