@pikku/openapi-parser 0.12.20 → 0.12.21
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 +25 -0
- package/dist/auth-config.d.ts +3 -3
- package/package.json +8 -6
- package/src/auth-config.ts +0 -94
- package/src/codegen.test.ts +0 -973
- package/src/codegen.ts +0 -1670
- package/src/index.ts +0 -24
- package/src/naming.test.ts +0 -260
- package/src/naming.ts +0 -369
- package/src/openapi-to-zod-schema.test.ts +0 -664
- package/src/openapi-to-zod-schema.ts +0 -583
- package/src/parse-openapi.test.ts +0 -316
- package/src/parse-openapi.ts +0 -467
- package/src/zod-codegen.ts +0 -9
- package/tsconfig.json +0 -12
- package/tsconfig.tsbuildinfo +0 -1
|
@@ -1,583 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Converts OpenAPI schemas into Zod code strings.
|
|
3
|
-
*
|
|
4
|
-
* Chaining order: base → refinements → .nullable() → .optional() → .default() → .describe()
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
export interface OpenAPISchema {
|
|
8
|
-
type?: string
|
|
9
|
-
format?: string
|
|
10
|
-
description?: string
|
|
11
|
-
enum?: unknown[]
|
|
12
|
-
items?: OpenAPISchema
|
|
13
|
-
properties?: Record<string, OpenAPISchema>
|
|
14
|
-
required?: string[]
|
|
15
|
-
nullable?: boolean
|
|
16
|
-
default?: unknown
|
|
17
|
-
minimum?: number
|
|
18
|
-
maximum?: number
|
|
19
|
-
exclusiveMinimum?: number | boolean
|
|
20
|
-
exclusiveMaximum?: number | boolean
|
|
21
|
-
minLength?: number
|
|
22
|
-
maxLength?: number
|
|
23
|
-
pattern?: string
|
|
24
|
-
minItems?: number
|
|
25
|
-
maxItems?: number
|
|
26
|
-
oneOf?: OpenAPISchema[]
|
|
27
|
-
anyOf?: OpenAPISchema[]
|
|
28
|
-
allOf?: OpenAPISchema[]
|
|
29
|
-
additionalProperties?: boolean | OpenAPISchema
|
|
30
|
-
$ref?: string
|
|
31
|
-
// Zod-relevant extensions
|
|
32
|
-
readOnly?: boolean
|
|
33
|
-
writeOnly?: boolean
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export interface ZodCodegenContext {
|
|
37
|
-
/** Map from component schema name to its Zod variable name */
|
|
38
|
-
schemaRefs: Map<string, string>
|
|
39
|
-
/** Track which refs are actually used */
|
|
40
|
-
usedRefs: Set<string>
|
|
41
|
-
/** Indent level for readability */
|
|
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
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export function createContext(
|
|
59
|
-
schemaRefs?: Map<string, string>,
|
|
60
|
-
schemaIdentityMap?: Map<object, string>,
|
|
61
|
-
opts?: { camelCase?: boolean }
|
|
62
|
-
): ZodCodegenContext {
|
|
63
|
-
return {
|
|
64
|
-
schemaRefs: schemaRefs ?? new Map(),
|
|
65
|
-
usedRefs: new Set(),
|
|
66
|
-
indent: 0,
|
|
67
|
-
seen: new Set(),
|
|
68
|
-
schemaIdentityMap: schemaIdentityMap ?? new Map(),
|
|
69
|
-
depth: 0,
|
|
70
|
-
camelCase: opts?.camelCase,
|
|
71
|
-
}
|
|
72
|
-
}
|
|
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
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Convert a single OpenAPI schema to a Zod expression string.
|
|
81
|
-
* Does NOT include .optional() — that's handled at the property level based on `required`.
|
|
82
|
-
*/
|
|
83
|
-
export function schemaToZod(
|
|
84
|
-
schema: OpenAPISchema,
|
|
85
|
-
ctx: ZodCodegenContext,
|
|
86
|
-
opts: { optional?: boolean } = {}
|
|
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
|
-
}
|
|
99
|
-
let code = schemaToZodBase(schema, ctx)
|
|
100
|
-
|
|
101
|
-
// Refinements
|
|
102
|
-
code = applyRefinements(code, schema)
|
|
103
|
-
|
|
104
|
-
// Nullable
|
|
105
|
-
if (schema.nullable) {
|
|
106
|
-
code += '.nullable()'
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// Optional
|
|
110
|
-
if (opts.optional) {
|
|
111
|
-
code += '.optional()'
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// Default — validate type compatibility and coerce mismatches
|
|
115
|
-
if (schema.default !== undefined) {
|
|
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
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
// Description — sanitize */ to prevent breaking JSDoc comments downstream
|
|
189
|
-
if (schema.description) {
|
|
190
|
-
const safeDesc = schema.description.replace(/\*\//g, '* /')
|
|
191
|
-
code += `.describe(${JSON.stringify(safeDesc)})`
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
return code
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function schemaToZodBase(
|
|
198
|
-
schema: OpenAPISchema,
|
|
199
|
-
ctx: ZodCodegenContext
|
|
200
|
-
): string {
|
|
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)
|
|
220
|
-
if (schema.$ref) {
|
|
221
|
-
const refName = schema.$ref.split('/').pop()!
|
|
222
|
-
const zodName = ctx.schemaRefs.get(refName)
|
|
223
|
-
if (zodName) {
|
|
224
|
-
ctx.usedRefs.add(refName)
|
|
225
|
-
return zodName
|
|
226
|
-
}
|
|
227
|
-
// Unknown ref — fallback to z.unknown()
|
|
228
|
-
return 'z.unknown()'
|
|
229
|
-
}
|
|
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
|
-
|
|
244
|
-
// Handle allOf — merge into single object
|
|
245
|
-
if (schema.allOf && schema.allOf.length > 0) {
|
|
246
|
-
return handleAllOf(schema.allOf, ctx)
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// Handle oneOf/anyOf — union
|
|
250
|
-
if (schema.oneOf && schema.oneOf.length > 0) {
|
|
251
|
-
return handleUnion(schema.oneOf, ctx)
|
|
252
|
-
}
|
|
253
|
-
if (schema.anyOf && schema.anyOf.length > 0) {
|
|
254
|
-
return handleUnion(schema.anyOf, ctx)
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
// Handle enum
|
|
258
|
-
if (schema.enum && schema.enum.length > 0) {
|
|
259
|
-
return handleEnum(schema.enum)
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
// Handle by type
|
|
263
|
-
switch (schema.type) {
|
|
264
|
-
case 'string':
|
|
265
|
-
return handleString(schema)
|
|
266
|
-
case 'integer':
|
|
267
|
-
return 'z.number().int()'
|
|
268
|
-
case 'number':
|
|
269
|
-
return 'z.number()'
|
|
270
|
-
case 'boolean':
|
|
271
|
-
return 'z.boolean()'
|
|
272
|
-
case 'array':
|
|
273
|
-
return handleArray(schema, ctx)
|
|
274
|
-
case 'object':
|
|
275
|
-
return handleObject(schema, ctx)
|
|
276
|
-
default:
|
|
277
|
-
// No type specified but has properties — treat as object
|
|
278
|
-
if (schema.properties) {
|
|
279
|
-
return handleObject(schema, ctx)
|
|
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
|
-
}
|
|
290
|
-
return 'z.unknown()'
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
function handleString(schema: OpenAPISchema): string {
|
|
295
|
-
switch (schema.format) {
|
|
296
|
-
case 'uuid':
|
|
297
|
-
return 'z.string().uuid()'
|
|
298
|
-
case 'date-time':
|
|
299
|
-
return 'z.string().datetime()'
|
|
300
|
-
case 'date':
|
|
301
|
-
return 'z.string().date()'
|
|
302
|
-
case 'email':
|
|
303
|
-
return 'z.string().email()'
|
|
304
|
-
case 'uri':
|
|
305
|
-
case 'url':
|
|
306
|
-
return 'z.string().url()'
|
|
307
|
-
default:
|
|
308
|
-
return 'z.string()'
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function handleEnum(values: unknown[]): string {
|
|
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])})`
|
|
323
|
-
}
|
|
324
|
-
if (primitives.every((v) => typeof v === 'string')) {
|
|
325
|
-
const enumValues = primitives.map((v) => JSON.stringify(v)).join(', ')
|
|
326
|
-
return `z.enum([${enumValues}])`
|
|
327
|
-
}
|
|
328
|
-
const literals = primitives
|
|
329
|
-
.map((v) => `z.literal(${JSON.stringify(v)})`)
|
|
330
|
-
.join(', ')
|
|
331
|
-
return `z.union([${literals}])`
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function handleArray(schema: OpenAPISchema, ctx: ZodCodegenContext): string {
|
|
335
|
-
const innerCtx = { ...ctx, depth: ctx.depth + 1 }
|
|
336
|
-
const itemsZod = schema.items
|
|
337
|
-
? schemaToZodBase(schema.items, innerCtx)
|
|
338
|
-
: 'z.unknown()'
|
|
339
|
-
return `z.array(${itemsZod})`
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
function indent(ctx: ZodCodegenContext): string {
|
|
343
|
-
return ' '.repeat(ctx.indent)
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function handleObject(schema: OpenAPISchema, ctx: ZodCodegenContext): string {
|
|
347
|
-
if (!schema.properties || Object.keys(schema.properties).length === 0) {
|
|
348
|
-
// Object with no defined properties
|
|
349
|
-
if (schema.additionalProperties) {
|
|
350
|
-
const valueSchema =
|
|
351
|
-
typeof schema.additionalProperties === 'object'
|
|
352
|
-
? schemaToZod(schema.additionalProperties, ctx)
|
|
353
|
-
: 'z.unknown()'
|
|
354
|
-
return `z.record(z.string(), ${valueSchema})`
|
|
355
|
-
}
|
|
356
|
-
return 'z.record(z.string(), z.unknown())'
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
const inner: ZodCodegenContext = {
|
|
360
|
-
...ctx,
|
|
361
|
-
indent: ctx.indent + 1,
|
|
362
|
-
depth: ctx.depth + 1,
|
|
363
|
-
}
|
|
364
|
-
const pad = indent(inner)
|
|
365
|
-
const closePad = indent(ctx)
|
|
366
|
-
const requiredSet = new Set(schema.required ?? [])
|
|
367
|
-
const entries: string[] = []
|
|
368
|
-
|
|
369
|
-
for (const [key, propSchema] of Object.entries(schema.properties)) {
|
|
370
|
-
const isOptional = !requiredSet.has(key)
|
|
371
|
-
const propZod = schemaToZod(propSchema, inner, { optional: isOptional })
|
|
372
|
-
const outputKey = ctx.camelCase ? snakeToCamel(key) : key
|
|
373
|
-
entries.push(`${pad}${safeKey(outputKey)}: ${propZod},`)
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
return `z.object({\n${entries.join('\n')}\n${closePad}})`
|
|
377
|
-
}
|
|
378
|
-
|
|
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
|
-
|
|
394
|
-
// Collect all properties from all schemas
|
|
395
|
-
const mergedProps: Record<string, OpenAPISchema> = {}
|
|
396
|
-
const mergedRequired: string[] = []
|
|
397
|
-
|
|
398
|
-
for (const sub of schemas) {
|
|
399
|
-
const refName = getRefName(sub)
|
|
400
|
-
if (refName) {
|
|
401
|
-
const zodName = ctx.schemaRefs.get(refName)
|
|
402
|
-
if (zodName) {
|
|
403
|
-
ctx.usedRefs.add(refName)
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
if (sub.properties) {
|
|
407
|
-
Object.assign(mergedProps, sub.properties)
|
|
408
|
-
}
|
|
409
|
-
if (sub.required) {
|
|
410
|
-
mergedRequired.push(...sub.required)
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
// If we have only refs, use .merge()
|
|
415
|
-
const refSchemas = schemas.filter((s) => getRefName(s) !== undefined)
|
|
416
|
-
const objSchemas = schemas.filter(
|
|
417
|
-
(s) => getRefName(s) === undefined && (s.properties || s.type === 'object')
|
|
418
|
-
)
|
|
419
|
-
|
|
420
|
-
if (refSchemas.length > 0 && objSchemas.length === 0) {
|
|
421
|
-
const parts = refSchemas.map((s) => {
|
|
422
|
-
const refName = getRefName(s)!
|
|
423
|
-
ctx.usedRefs.add(refName)
|
|
424
|
-
return ctx.schemaRefs.get(refName) || 'z.object({})'
|
|
425
|
-
})
|
|
426
|
-
if (parts.length === 1) return parts[0]
|
|
427
|
-
return parts.reduce((acc, part) => `${acc}.merge(${part})`)
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
// Otherwise, merge into a single object
|
|
431
|
-
if (Object.keys(mergedProps).length > 0) {
|
|
432
|
-
return handleObject(
|
|
433
|
-
{
|
|
434
|
-
type: 'object',
|
|
435
|
-
properties: mergedProps,
|
|
436
|
-
required: [...new Set(mergedRequired)],
|
|
437
|
-
},
|
|
438
|
-
ctx
|
|
439
|
-
)
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
// Fallback
|
|
443
|
-
return 'z.unknown()'
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
function handleUnion(schemas: OpenAPISchema[], ctx: ZodCodegenContext): string {
|
|
447
|
-
const members = schemas.map((s) => schemaToZodBase(s, ctx))
|
|
448
|
-
if (members.length === 1) return members[0]
|
|
449
|
-
return `z.union([${members.join(', ')}])`
|
|
450
|
-
}
|
|
451
|
-
|
|
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
|
-
}
|
|
469
|
-
let result = code
|
|
470
|
-
|
|
471
|
-
// String refinements — only apply to explicit string types
|
|
472
|
-
if (schema.type === 'string' && !schema.enum) {
|
|
473
|
-
if (schema.minLength !== undefined) {
|
|
474
|
-
result += `.min(${schema.minLength})`
|
|
475
|
-
}
|
|
476
|
-
if (schema.maxLength !== undefined) {
|
|
477
|
-
result += `.max(${schema.maxLength})`
|
|
478
|
-
}
|
|
479
|
-
if (schema.pattern) {
|
|
480
|
-
result += `.regex(new RegExp(${JSON.stringify(schema.pattern)}))`
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
|
|
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'
|
|
497
|
-
if (schema.minimum !== undefined) {
|
|
498
|
-
if (exMinIsBoolean && schema.exclusiveMinimum) {
|
|
499
|
-
result += `.gt(${schema.minimum})`
|
|
500
|
-
} else {
|
|
501
|
-
result += `.min(${schema.minimum})`
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
if (schema.maximum !== undefined) {
|
|
505
|
-
if (exMaxIsBoolean && schema.exclusiveMaximum) {
|
|
506
|
-
result += `.lt(${schema.maximum})`
|
|
507
|
-
} else {
|
|
508
|
-
result += `.max(${schema.maximum})`
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
if (!exMinIsBoolean && schema.exclusiveMinimum !== undefined) {
|
|
512
|
-
result += `.gt(${schema.exclusiveMinimum})`
|
|
513
|
-
}
|
|
514
|
-
if (!exMaxIsBoolean && schema.exclusiveMaximum !== undefined) {
|
|
515
|
-
result += `.lt(${schema.exclusiveMaximum})`
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
// Array refinements
|
|
520
|
-
if (schema.type === 'array') {
|
|
521
|
-
if (schema.minItems !== undefined) {
|
|
522
|
-
result += `.min(${schema.minItems})`
|
|
523
|
-
}
|
|
524
|
-
if (schema.maxItems !== undefined) {
|
|
525
|
-
result += `.max(${schema.maxItems})`
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
return result
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
/** Ensure property keys are safe identifiers, or quote them */
|
|
533
|
-
function safeKey(key: string): string {
|
|
534
|
-
if (/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key)) {
|
|
535
|
-
return key
|
|
536
|
-
}
|
|
537
|
-
return JSON.stringify(key)
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
/**
|
|
541
|
-
* Generate the Zod variable name for a component schema.
|
|
542
|
-
* e.g. "PaginatedResponse" → "PaginatedResponseSchema"
|
|
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
|
-
|
|
566
|
-
export function sanitizeTypeName(name: string): string {
|
|
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
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
export function schemaVarName(name: string): string {
|
|
581
|
-
const sanitized = sanitizeTypeName(name)
|
|
582
|
-
return sanitized.endsWith('Schema') ? sanitized : `${sanitized}Schema`
|
|
583
|
-
}
|