@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.
@@ -16,8 +16,8 @@ export interface OpenAPISchema {
16
16
  default?: unknown
17
17
  minimum?: number
18
18
  maximum?: number
19
- exclusiveMinimum?: number
20
- exclusiveMaximum?: number
19
+ exclusiveMinimum?: number | boolean
20
+ exclusiveMaximum?: number | boolean
21
21
  minLength?: number
22
22
  maxLength?: number
23
23
  pattern?: string
@@ -40,18 +40,42 @@ export interface ZodCodegenContext {
40
40
  usedRefs: Set<string>
41
41
  /** Indent level for readability */
42
42
  indent: number
43
+ /** Track schemas currently being processed to detect cycles */
44
+ seen: Set<OpenAPISchema>
45
+ /**
46
+ * Map from resolved schema object identity to its component name.
47
+ * After $ref resolution, many references lose their $ref string and
48
+ * become the same JS object as the component schema. This map lets
49
+ * schemaToZod detect those resolved references by identity.
50
+ */
51
+ schemaIdentityMap: Map<object, string>
52
+ /** Current nesting depth for preventing excessively deep types */
53
+ depth: number
54
+ /** When true, convert snake_case property keys to camelCase in z.object() */
55
+ camelCase?: boolean
43
56
  }
44
57
 
45
58
  export function createContext(
46
- schemaRefs?: Map<string, string>
59
+ schemaRefs?: Map<string, string>,
60
+ schemaIdentityMap?: Map<object, string>,
61
+ opts?: { camelCase?: boolean }
47
62
  ): ZodCodegenContext {
48
63
  return {
49
64
  schemaRefs: schemaRefs ?? new Map(),
50
65
  usedRefs: new Set(),
51
66
  indent: 0,
67
+ seen: new Set(),
68
+ schemaIdentityMap: schemaIdentityMap ?? new Map(),
69
+ depth: 0,
70
+ camelCase: opts?.camelCase,
52
71
  }
53
72
  }
54
73
 
74
+ /** Convert a snake_case or kebab-case string to camelCase */
75
+ export function snakeToCamel(s: string): string {
76
+ return s.replace(/[-_]+(.)/g, (_, c) => c.toUpperCase())
77
+ }
78
+
55
79
  /**
56
80
  * Convert a single OpenAPI schema to a Zod expression string.
57
81
  * Does NOT include .optional() — that's handled at the property level based on `required`.
@@ -61,6 +85,17 @@ export function schemaToZod(
61
85
  ctx: ZodCodegenContext,
62
86
  opts: { optional?: boolean } = {}
63
87
  ): string {
88
+ // Bug 5: Depth limit to prevent TS2589 "excessively deep" type errors
89
+ if (ctx.depth > 10) {
90
+ let code = 'z.any()'
91
+ if (schema.nullable) code += '.nullable()'
92
+ if (opts.optional) code += '.optional()'
93
+ if (schema.description) {
94
+ const safeDesc = schema.description.replace(/\*\//g, '* /')
95
+ code += `.describe(${JSON.stringify(safeDesc)})`
96
+ }
97
+ return code
98
+ }
64
99
  let code = schemaToZodBase(schema, ctx)
65
100
 
66
101
  // Refinements
@@ -76,14 +111,84 @@ export function schemaToZod(
76
111
  code += '.optional()'
77
112
  }
78
113
 
79
- // Default
114
+ // Default — validate type compatibility and coerce mismatches
80
115
  if (schema.default !== undefined) {
81
- code += `.default(${JSON.stringify(schema.default)})`
116
+ let defaultValue = schema.default
117
+ if (schema.enum && schema.enum.length > 0) {
118
+ // For enum schemas, the default must exactly match one of the enum values.
119
+ // Don't apply type coercion — the Zod schema uses the original enum types
120
+ // (e.g. z.literal(1) not z.literal("1")), so coercing would cause TS2769.
121
+ const enumValues = new Set(
122
+ schema.enum.map((v: unknown) => JSON.stringify(v))
123
+ )
124
+ if (enumValues.has(JSON.stringify(defaultValue))) {
125
+ code += `.default(${JSON.stringify(defaultValue)})`
126
+ }
127
+ // else: default not in enum — skip it entirely
128
+ } else if (defaultValue === null && !schema.nullable) {
129
+ // skip null defaults unless nullable
130
+ } else if (schema.type === 'object' && typeof defaultValue === 'string') {
131
+ // skip string defaults on object types (often a stray description)
132
+ } else if (
133
+ schema.properties &&
134
+ typeof defaultValue === 'object' &&
135
+ defaultValue !== null &&
136
+ !Array.isArray(defaultValue)
137
+ ) {
138
+ // For object schemas with properties, validate default keys match schema properties.
139
+ // Extra keys in the default cause TS2769 because z.object() rejects unknown keys.
140
+ const schemaKeys = new Set(Object.keys(schema.properties))
141
+ const defaultKeys = Object.keys(defaultValue as Record<string, unknown>)
142
+ const hasExtraKeys = defaultKeys.some((k) => !schemaKeys.has(k))
143
+ if (!hasExtraKeys) {
144
+ code += `.default(${JSON.stringify(defaultValue)})`
145
+ }
146
+ // else: default has keys not in schema — skip it
147
+ } else if (
148
+ schema.type === 'string' &&
149
+ typeof defaultValue === 'object' &&
150
+ defaultValue !== null
151
+ ) {
152
+ // skip object/array defaults on string types
153
+ } else if (
154
+ (!schema.type || schema.type === 'object') &&
155
+ schema.properties &&
156
+ typeof defaultValue === 'string'
157
+ ) {
158
+ // skip string defaults on implicit object types (has properties but no explicit type)
159
+ } else if (schema.type === 'array' && !Array.isArray(defaultValue)) {
160
+ // skip type-mismatched defaults (non-array default on array type)
161
+ } else if (schema.type !== 'array' && Array.isArray(defaultValue)) {
162
+ // skip array defaults on non-array types
163
+ } else if (schema.type === 'boolean' && typeof defaultValue === 'string') {
164
+ defaultValue = defaultValue === 'true'
165
+ code += `.default(${JSON.stringify(defaultValue)})`
166
+ } else if (
167
+ (schema.type === 'number' || schema.type === 'integer') &&
168
+ typeof defaultValue === 'string'
169
+ ) {
170
+ const parsed = Number(defaultValue)
171
+ if (!isNaN(parsed)) code += `.default(${JSON.stringify(parsed)})`
172
+ } else if (schema.type === 'string' && typeof defaultValue === 'number') {
173
+ code += `.default(${JSON.stringify(String(defaultValue))})`
174
+ } else if (schema.type === 'string' && typeof defaultValue === 'boolean') {
175
+ code += `.default(${JSON.stringify(String(defaultValue))})`
176
+ } else if (schema.type === 'boolean' && typeof defaultValue !== 'boolean') {
177
+ code += `.default(${defaultValue === 'true' || defaultValue === 1})`
178
+ } else if (
179
+ (schema.type === 'number' || schema.type === 'integer') &&
180
+ typeof defaultValue === 'boolean'
181
+ ) {
182
+ code += `.default(${defaultValue ? 1 : 0})`
183
+ } else {
184
+ code += `.default(${JSON.stringify(defaultValue)})`
185
+ }
82
186
  }
83
187
 
84
- // Description
188
+ // Description — sanitize */ to prevent breaking JSDoc comments downstream
85
189
  if (schema.description) {
86
- code += `.describe(${JSON.stringify(schema.description)})`
190
+ const safeDesc = schema.description.replace(/\*\//g, '* /')
191
+ code += `.describe(${JSON.stringify(safeDesc)})`
87
192
  }
88
193
 
89
194
  return code
@@ -93,7 +198,25 @@ function schemaToZodBase(
93
198
  schema: OpenAPISchema,
94
199
  ctx: ZodCodegenContext
95
200
  ): string {
96
- // Handle $ref
201
+ // Cycle detection: if we've already started processing this exact schema
202
+ // object, return z.any() to break infinite recursion
203
+ if (ctx.seen.has(schema)) {
204
+ return 'z.any()'
205
+ }
206
+ ctx.seen.add(schema)
207
+
208
+ try {
209
+ return schemaToZodBaseInner(schema, ctx)
210
+ } finally {
211
+ ctx.seen.delete(schema)
212
+ }
213
+ }
214
+
215
+ function schemaToZodBaseInner(
216
+ schema: OpenAPISchema,
217
+ ctx: ZodCodegenContext
218
+ ): string {
219
+ // Handle $ref (surviving after resolution)
97
220
  if (schema.$ref) {
98
221
  const refName = schema.$ref.split('/').pop()!
99
222
  const zodName = ctx.schemaRefs.get(refName)
@@ -105,6 +228,19 @@ function schemaToZodBase(
105
228
  return 'z.unknown()'
106
229
  }
107
230
 
231
+ // Handle resolved $ref by object identity: if this schema object IS a
232
+ // known component schema, reference the variable instead of inlining.
233
+ if (ctx.schemaIdentityMap.size > 0) {
234
+ const identityName = ctx.schemaIdentityMap.get(schema as object)
235
+ if (identityName) {
236
+ const zodName = ctx.schemaRefs.get(identityName)
237
+ if (zodName) {
238
+ ctx.usedRefs.add(identityName)
239
+ return zodName
240
+ }
241
+ }
242
+ }
243
+
108
244
  // Handle allOf — merge into single object
109
245
  if (schema.allOf && schema.allOf.length > 0) {
110
246
  return handleAllOf(schema.allOf, ctx)
@@ -142,6 +278,15 @@ function schemaToZodBase(
142
278
  if (schema.properties) {
143
279
  return handleObject(schema, ctx)
144
280
  }
281
+ // Bug 4: Infer number type when minimum/maximum present but no type
282
+ if (
283
+ schema.minimum !== undefined ||
284
+ schema.maximum !== undefined ||
285
+ schema.exclusiveMinimum !== undefined ||
286
+ schema.exclusiveMaximum !== undefined
287
+ ) {
288
+ return 'z.number()'
289
+ }
145
290
  return 'z.unknown()'
146
291
  }
147
292
  }
@@ -165,25 +310,31 @@ function handleString(schema: OpenAPISchema): string {
165
310
  }
166
311
 
167
312
  function handleEnum(values: unknown[]): string {
168
- // Single value — use z.literal
169
- if (values.length === 1) {
170
- return `z.literal(${JSON.stringify(values[0])})`
313
+ const primitives = values.filter(
314
+ (v) =>
315
+ v === null ||
316
+ typeof v === 'string' ||
317
+ typeof v === 'number' ||
318
+ typeof v === 'boolean'
319
+ )
320
+ if (primitives.length === 0) return 'z.unknown()'
321
+ if (primitives.length === 1) {
322
+ return `z.literal(${JSON.stringify(primitives[0])})`
171
323
  }
172
- // If all values are strings, use z.enum
173
- if (values.every((v) => typeof v === 'string')) {
174
- const enumValues = values.map((v) => JSON.stringify(v)).join(', ')
324
+ if (primitives.every((v) => typeof v === 'string')) {
325
+ const enumValues = primitives.map((v) => JSON.stringify(v)).join(', ')
175
326
  return `z.enum([${enumValues}])`
176
327
  }
177
- // Mixed types — use z.union of z.literal
178
- const literals = values
328
+ const literals = primitives
179
329
  .map((v) => `z.literal(${JSON.stringify(v)})`)
180
330
  .join(', ')
181
331
  return `z.union([${literals}])`
182
332
  }
183
333
 
184
334
  function handleArray(schema: OpenAPISchema, ctx: ZodCodegenContext): string {
335
+ const innerCtx = { ...ctx, depth: ctx.depth + 1 }
185
336
  const itemsZod = schema.items
186
- ? schemaToZodBase(schema.items, ctx)
337
+ ? schemaToZodBase(schema.items, innerCtx)
187
338
  : 'z.unknown()'
188
339
  return `z.array(${itemsZod})`
189
340
  }
@@ -205,7 +356,11 @@ function handleObject(schema: OpenAPISchema, ctx: ZodCodegenContext): string {
205
356
  return 'z.record(z.string(), z.unknown())'
206
357
  }
207
358
 
208
- const inner: ZodCodegenContext = { ...ctx, indent: ctx.indent + 1 }
359
+ const inner: ZodCodegenContext = {
360
+ ...ctx,
361
+ indent: ctx.indent + 1,
362
+ depth: ctx.depth + 1,
363
+ }
209
364
  const pad = indent(inner)
210
365
  const closePad = indent(ctx)
211
366
  const requiredSet = new Set(schema.required ?? [])
@@ -214,25 +369,38 @@ function handleObject(schema: OpenAPISchema, ctx: ZodCodegenContext): string {
214
369
  for (const [key, propSchema] of Object.entries(schema.properties)) {
215
370
  const isOptional = !requiredSet.has(key)
216
371
  const propZod = schemaToZod(propSchema, inner, { optional: isOptional })
217
- entries.push(`${pad}${safeKey(key)}: ${propZod},`)
372
+ const outputKey = ctx.camelCase ? snakeToCamel(key) : key
373
+ entries.push(`${pad}${safeKey(outputKey)}: ${propZod},`)
218
374
  }
219
375
 
220
376
  return `z.object({\n${entries.join('\n')}\n${closePad}})`
221
377
  }
222
378
 
223
379
  function handleAllOf(schemas: OpenAPISchema[], ctx: ZodCodegenContext): string {
380
+ // Helper to check if a sub-schema is a reference (by $ref or identity map)
381
+ function getRefName(sub: OpenAPISchema): string | undefined {
382
+ if (sub.$ref) {
383
+ return sub.$ref.split('/').pop()!
384
+ }
385
+ if (ctx.schemaIdentityMap.size > 0) {
386
+ const identityName = ctx.schemaIdentityMap.get(sub as object)
387
+ if (identityName && ctx.schemaRefs.has(identityName)) {
388
+ return identityName
389
+ }
390
+ }
391
+ return undefined
392
+ }
393
+
224
394
  // Collect all properties from all schemas
225
395
  const mergedProps: Record<string, OpenAPISchema> = {}
226
396
  const mergedRequired: string[] = []
227
397
 
228
398
  for (const sub of schemas) {
229
- if (sub.$ref) {
230
- const refName = sub.$ref.split('/').pop()!
399
+ const refName = getRefName(sub)
400
+ if (refName) {
231
401
  const zodName = ctx.schemaRefs.get(refName)
232
402
  if (zodName) {
233
403
  ctx.usedRefs.add(refName)
234
- // If it's a pure ref in an allOf, we'd need to merge/extend
235
- // For simplicity, if we have refs mixed with objects, use .merge()
236
404
  }
237
405
  }
238
406
  if (sub.properties) {
@@ -244,14 +412,14 @@ function handleAllOf(schemas: OpenAPISchema[], ctx: ZodCodegenContext): string {
244
412
  }
245
413
 
246
414
  // If we have only refs, use .merge()
247
- const refSchemas = schemas.filter((s) => s.$ref)
415
+ const refSchemas = schemas.filter((s) => getRefName(s) !== undefined)
248
416
  const objSchemas = schemas.filter(
249
- (s) => !s.$ref && (s.properties || s.type === 'object')
417
+ (s) => getRefName(s) === undefined && (s.properties || s.type === 'object')
250
418
  )
251
419
 
252
420
  if (refSchemas.length > 0 && objSchemas.length === 0) {
253
421
  const parts = refSchemas.map((s) => {
254
- const refName = s.$ref!.split('/').pop()!
422
+ const refName = getRefName(s)!
255
423
  ctx.usedRefs.add(refName)
256
424
  return ctx.schemaRefs.get(refName) || 'z.object({})'
257
425
  })
@@ -282,10 +450,26 @@ function handleUnion(schemas: OpenAPISchema[], ctx: ZodCodegenContext): string {
282
450
  }
283
451
 
284
452
  function applyRefinements(code: string, schema: OpenAPISchema): string {
453
+ // Don't apply refinements to types that don't support .min()/.max()/.regex() etc.
454
+ // This includes z.any(), z.unknown(), z.union(...), z.literal(...), z.enum(...),
455
+ // z.lazy(...), z.record(...), and schema references (variable names without z. prefix).
456
+ if (
457
+ code === 'z.any()' ||
458
+ code === 'z.unknown()' ||
459
+ code.startsWith('z.union(') ||
460
+ code.startsWith('z.literal(') ||
461
+ code.startsWith('z.enum(') ||
462
+ code.startsWith('z.lazy(') ||
463
+ code.startsWith('z.record(') ||
464
+ // Schema variable references (e.g. FooSchema) — no z. prefix
465
+ (!code.startsWith('z.') && /^[a-zA-Z_$]/.test(code))
466
+ ) {
467
+ return code
468
+ }
285
469
  let result = code
286
470
 
287
- // String refinements
288
- if (schema.type === 'string' || (!schema.type && !schema.format)) {
471
+ // String refinements — only apply to explicit string types
472
+ if (schema.type === 'string' && !schema.enum) {
289
473
  if (schema.minLength !== undefined) {
290
474
  result += `.min(${schema.minLength})`
291
475
  }
@@ -297,18 +481,37 @@ function applyRefinements(code: string, schema: OpenAPISchema): string {
297
481
  }
298
482
  }
299
483
 
300
- // Number refinements
301
- if (schema.type === 'number' || schema.type === 'integer') {
484
+ // Number refinements — also apply when no type but min/max present (inferred number)
485
+ const isNumberType =
486
+ schema.type === 'number' ||
487
+ schema.type === 'integer' ||
488
+ (!schema.type &&
489
+ !schema.properties &&
490
+ (schema.minimum !== undefined ||
491
+ schema.maximum !== undefined ||
492
+ schema.exclusiveMinimum !== undefined ||
493
+ schema.exclusiveMaximum !== undefined))
494
+ if (isNumberType && !schema.enum) {
495
+ const exMinIsBoolean = typeof schema.exclusiveMinimum === 'boolean'
496
+ const exMaxIsBoolean = typeof schema.exclusiveMaximum === 'boolean'
302
497
  if (schema.minimum !== undefined) {
303
- result += `.min(${schema.minimum})`
498
+ if (exMinIsBoolean && schema.exclusiveMinimum) {
499
+ result += `.gt(${schema.minimum})`
500
+ } else {
501
+ result += `.min(${schema.minimum})`
502
+ }
304
503
  }
305
504
  if (schema.maximum !== undefined) {
306
- result += `.max(${schema.maximum})`
505
+ if (exMaxIsBoolean && schema.exclusiveMaximum) {
506
+ result += `.lt(${schema.maximum})`
507
+ } else {
508
+ result += `.max(${schema.maximum})`
509
+ }
307
510
  }
308
- if (schema.exclusiveMinimum !== undefined) {
511
+ if (!exMinIsBoolean && schema.exclusiveMinimum !== undefined) {
309
512
  result += `.gt(${schema.exclusiveMinimum})`
310
513
  }
311
- if (schema.exclusiveMaximum !== undefined) {
514
+ if (!exMaxIsBoolean && schema.exclusiveMaximum !== undefined) {
312
515
  result += `.lt(${schema.exclusiveMaximum})`
313
516
  }
314
517
  }
@@ -338,8 +541,40 @@ function safeKey(key: string): string {
338
541
  * Generate the Zod variable name for a component schema.
339
542
  * e.g. "PaginatedResponse" → "PaginatedResponseSchema"
340
543
  */
544
+ const RESERVED_TYPE_NAMES = new Set([
545
+ 'object',
546
+ 'string',
547
+ 'number',
548
+ 'boolean',
549
+ 'symbol',
550
+ 'undefined',
551
+ 'null',
552
+ 'void',
553
+ 'never',
554
+ 'any',
555
+ 'unknown',
556
+ 'bigint',
557
+ 'function',
558
+ 'class',
559
+ 'enum',
560
+ 'interface',
561
+ 'type',
562
+ 'import',
563
+ 'export',
564
+ ])
565
+
341
566
  export function sanitizeTypeName(name: string): string {
342
- return name.replace(/[^a-zA-Z0-9_$]/g, '')
567
+ // Strip numeric separator patterns (e.g. 1_000 → 1000)
568
+ let cleaned = name.replace(/(\d)_(\d)/g, '$1$2')
569
+ cleaned = cleaned.replace(/[^a-zA-Z0-9_$]/g, '')
570
+ // Prefix with 'n' if starts with a digit
571
+ if (/^[0-9]/.test(cleaned)) {
572
+ cleaned = 'n' + cleaned
573
+ }
574
+ if (RESERVED_TYPE_NAMES.has(cleaned.toLowerCase())) {
575
+ return `${cleaned}Type`
576
+ }
577
+ return cleaned
343
578
  }
344
579
 
345
580
  export function schemaVarName(name: string): string {
@@ -169,13 +169,17 @@ export async function parseOpenAPISpec(filePath: string): Promise<ParsedSpec> {
169
169
  }
170
170
  }
171
171
 
172
- /** Recursively resolve $ref pointers in-place */
173
- function resolveRefs(node: any, root: any): any {
172
+ /** Recursively resolve $ref pointers in-place, with cycle detection */
173
+ function resolveRefs(node: any, root: any, visited?: Set<any>): any {
174
174
  if (node === null || typeof node !== 'object') return node
175
175
 
176
+ const seen = visited ?? new Set<any>()
177
+ if (seen.has(node)) return node
178
+ seen.add(node)
179
+
176
180
  if (Array.isArray(node)) {
177
181
  for (let i = 0; i < node.length; i++) {
178
- node[i] = resolveRefs(node[i], root)
182
+ node[i] = resolveRefs(node[i], root, seen)
179
183
  }
180
184
  return node
181
185
  }
@@ -186,14 +190,15 @@ function resolveRefs(node: any, root: any): any {
186
190
  // Merge any sibling properties (like description overrides)
187
191
  const { $ref, ...siblings } = node
188
192
  const result = { ...resolved, ...siblings }
189
- // Don't recurse infinitely — mark as resolved
193
+ // Don't recurse into the merged result — it shares structure with
194
+ // the original resolved object and may be circular
190
195
  return result
191
196
  }
192
197
  return node
193
198
  }
194
199
 
195
200
  for (const key of Object.keys(node)) {
196
- node[key] = resolveRefs(node[key], root)
201
+ node[key] = resolveRefs(node[key], root, seen)
197
202
  }
198
203
 
199
204
  return node
@@ -435,10 +440,18 @@ export function computeContractHash(spec: ParsedSpec): string {
435
440
  serverUrls: spec.serverUrls,
436
441
  }
437
442
 
438
- return createHash('sha256')
439
- .update(JSON.stringify(contract))
440
- .digest('hex')
441
- .slice(0, 16)
443
+ // Use a cycle-safe JSON serializer since resolved $ref schemas
444
+ // can contain circular object references
445
+ const seen = new WeakSet()
446
+ const json = JSON.stringify(contract, (_key, value) => {
447
+ if (typeof value === 'object' && value !== null) {
448
+ if (seen.has(value)) return '[Circular]'
449
+ seen.add(value)
450
+ }
451
+ return value
452
+ })
453
+
454
+ return createHash('sha256').update(json).digest('hex').slice(0, 16)
442
455
  }
443
456
 
444
457
  function extractTagDescriptions(doc: any): Record<string, string> {
@@ -5,4 +5,5 @@ export {
5
5
  schemaToZod,
6
6
  schemaVarName,
7
7
  sanitizeTypeName,
8
+ snakeToCamel,
8
9
  } from './openapi-to-zod-schema.js'