@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/CHANGELOG.md +15 -0
- package/dist/codegen.d.ts +1 -0
- package/dist/codegen.js +586 -66
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/naming.d.ts +7 -0
- package/dist/naming.js +136 -11
- package/dist/openapi-to-zod-schema.d.ts +20 -7
- package/dist/openapi-to-zod-schema.js +245 -33
- package/dist/parse-openapi.js +22 -9
- package/dist/zod-codegen.d.ts +1 -1
- package/dist/zod-codegen.js +1 -1
- package/package.json +1 -1
- package/src/codegen.test.ts +782 -0
- package/src/codegen.ts +726 -74
- package/src/index.ts +2 -3
- package/src/naming.test.ts +260 -0
- package/src/naming.ts +155 -14
- package/src/openapi-to-zod-schema.test.ts +664 -0
- package/src/openapi-to-zod-schema.ts +270 -35
- package/src/parse-openapi.ts +22 -9
- package/src/zod-codegen.ts +1 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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,
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
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
|
|
415
|
+
const refSchemas = schemas.filter((s) => getRefName(s) !== undefined)
|
|
248
416
|
const objSchemas = schemas.filter(
|
|
249
|
-
(s) =>
|
|
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
|
|
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'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
package/src/parse-openapi.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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> {
|