@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.
@@ -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
- }