@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/CHANGELOG.md +22 -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 +2 -2
- 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.test.ts +23 -8
- package/src/parse-openapi.ts +22 -9
- package/src/zod-codegen.ts +1 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,664 @@
|
|
|
1
|
+
import { strict as assert } from 'assert'
|
|
2
|
+
import { describe, test } from 'node:test'
|
|
3
|
+
import {
|
|
4
|
+
schemaToZod,
|
|
5
|
+
createContext,
|
|
6
|
+
schemaVarName,
|
|
7
|
+
sanitizeTypeName,
|
|
8
|
+
type OpenAPISchema,
|
|
9
|
+
type ZodCodegenContext,
|
|
10
|
+
} from './openapi-to-zod-schema.js'
|
|
11
|
+
|
|
12
|
+
function ctx(
|
|
13
|
+
schemaRefs?: Map<string, string>,
|
|
14
|
+
schemaIdentityMap?: Map<object, string>
|
|
15
|
+
): ZodCodegenContext {
|
|
16
|
+
return createContext(schemaRefs, schemaIdentityMap)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Description sanitization (bug: */ in descriptions breaks JSDoc)
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
describe('description sanitization', () => {
|
|
23
|
+
test('sanitizes */ in descriptions to prevent JSDoc breakage', () => {
|
|
24
|
+
const schema: OpenAPISchema = {
|
|
25
|
+
type: 'string',
|
|
26
|
+
description: 'Closes comment with */ and then continues',
|
|
27
|
+
}
|
|
28
|
+
const code = schemaToZod(schema, ctx())
|
|
29
|
+
// The description is JSON.stringify'd inside .describe(), so we check the
|
|
30
|
+
// actual string content by extracting the describe argument
|
|
31
|
+
assert.ok(!code.includes('*/'), `should not contain raw */ in: ${code}`)
|
|
32
|
+
assert.ok(code.includes('* /'), 'should replace */ with * /')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test('handles description with multiple */ occurrences', () => {
|
|
36
|
+
const schema: OpenAPISchema = {
|
|
37
|
+
type: 'string',
|
|
38
|
+
description: 'First */ and second */',
|
|
39
|
+
}
|
|
40
|
+
const code = schemaToZod(schema, ctx())
|
|
41
|
+
assert.ok(!code.includes('*/'), `should not contain raw */ in: ${code}`)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Enum with mixed types
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
describe('enum handling', () => {
|
|
49
|
+
test('filters non-primitive values from enum', () => {
|
|
50
|
+
const schema: OpenAPISchema = {
|
|
51
|
+
enum: ['active', 'inactive', { complex: true }, [1, 2]],
|
|
52
|
+
}
|
|
53
|
+
const code = schemaToZod(schema, ctx())
|
|
54
|
+
assert.ok(code.includes('z.enum'))
|
|
55
|
+
assert.ok(code.includes('"active"'))
|
|
56
|
+
assert.ok(code.includes('"inactive"'))
|
|
57
|
+
assert.ok(!code.includes('complex'))
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('handles enum with only non-primitive values', () => {
|
|
61
|
+
const schema: OpenAPISchema = {
|
|
62
|
+
enum: [{ a: 1 }, [1, 2]],
|
|
63
|
+
}
|
|
64
|
+
const code = schemaToZod(schema, ctx())
|
|
65
|
+
assert.equal(code, 'z.unknown()')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('handles enum with single value as z.literal', () => {
|
|
69
|
+
const schema: OpenAPISchema = {
|
|
70
|
+
enum: ['only'],
|
|
71
|
+
}
|
|
72
|
+
const code = schemaToZod(schema, ctx())
|
|
73
|
+
assert.ok(code.includes('z.literal'))
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
test('handles enum with mixed string and number as z.union of literals', () => {
|
|
77
|
+
const schema: OpenAPISchema = {
|
|
78
|
+
enum: ['alpha', 42, true],
|
|
79
|
+
}
|
|
80
|
+
const code = schemaToZod(schema, ctx())
|
|
81
|
+
assert.ok(code.includes('z.union'))
|
|
82
|
+
assert.ok(code.includes('z.literal("alpha")'))
|
|
83
|
+
assert.ok(code.includes('z.literal(42)'))
|
|
84
|
+
assert.ok(code.includes('z.literal(true)'))
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
test('allows null in enum values', () => {
|
|
88
|
+
const schema: OpenAPISchema = {
|
|
89
|
+
enum: ['yes', null],
|
|
90
|
+
}
|
|
91
|
+
const code = schemaToZod(schema, ctx())
|
|
92
|
+
assert.ok(code.includes('null'))
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Swagger 2.x exclusiveMinimum (boolean) vs OpenAPI 3.x (number)
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
describe('exclusiveMinimum / exclusiveMaximum handling', () => {
|
|
100
|
+
test('Swagger 2.x: boolean exclusiveMinimum with minimum → .gt()', () => {
|
|
101
|
+
const schema: OpenAPISchema = {
|
|
102
|
+
type: 'number',
|
|
103
|
+
minimum: 0,
|
|
104
|
+
exclusiveMinimum: true as any, // Swagger 2.x style
|
|
105
|
+
}
|
|
106
|
+
const code = schemaToZod(schema, ctx())
|
|
107
|
+
assert.ok(code.includes('.gt(0)'), `expected .gt(0), got: ${code}`)
|
|
108
|
+
assert.ok(!code.includes('.min('), 'should not also have .min()')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test('Swagger 2.x: boolean exclusiveMaximum with maximum → .lt()', () => {
|
|
112
|
+
const schema: OpenAPISchema = {
|
|
113
|
+
type: 'number',
|
|
114
|
+
maximum: 100,
|
|
115
|
+
exclusiveMaximum: true as any,
|
|
116
|
+
}
|
|
117
|
+
const code = schemaToZod(schema, ctx())
|
|
118
|
+
assert.ok(code.includes('.lt(100)'), `expected .lt(100), got: ${code}`)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('OpenAPI 3.x: numeric exclusiveMinimum → .gt()', () => {
|
|
122
|
+
const schema: OpenAPISchema = {
|
|
123
|
+
type: 'number',
|
|
124
|
+
exclusiveMinimum: 5,
|
|
125
|
+
}
|
|
126
|
+
const code = schemaToZod(schema, ctx())
|
|
127
|
+
assert.ok(code.includes('.gt(5)'), `expected .gt(5), got: ${code}`)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
test('OpenAPI 3.x: numeric exclusiveMaximum → .lt()', () => {
|
|
131
|
+
const schema: OpenAPISchema = {
|
|
132
|
+
type: 'integer',
|
|
133
|
+
exclusiveMaximum: 50,
|
|
134
|
+
}
|
|
135
|
+
const code = schemaToZod(schema, ctx())
|
|
136
|
+
assert.ok(code.includes('.lt(50)'), `expected .lt(50), got: ${code}`)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('non-exclusive minimum/maximum → .min()/.max()', () => {
|
|
140
|
+
const schema: OpenAPISchema = {
|
|
141
|
+
type: 'number',
|
|
142
|
+
minimum: 1,
|
|
143
|
+
maximum: 10,
|
|
144
|
+
}
|
|
145
|
+
const code = schemaToZod(schema, ctx())
|
|
146
|
+
assert.ok(code.includes('.min(1)'))
|
|
147
|
+
assert.ok(code.includes('.max(10)'))
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Reserved type names
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
describe('sanitizeTypeName', () => {
|
|
155
|
+
test('appends "Type" to reserved type names', () => {
|
|
156
|
+
assert.equal(sanitizeTypeName('Object'), 'ObjectType')
|
|
157
|
+
assert.equal(sanitizeTypeName('String'), 'StringType')
|
|
158
|
+
assert.equal(sanitizeTypeName('Number'), 'NumberType')
|
|
159
|
+
assert.equal(sanitizeTypeName('Boolean'), 'BooleanType')
|
|
160
|
+
assert.equal(sanitizeTypeName('Function'), 'FunctionType')
|
|
161
|
+
assert.equal(sanitizeTypeName('Class'), 'ClassType')
|
|
162
|
+
assert.equal(sanitizeTypeName('Enum'), 'EnumType')
|
|
163
|
+
assert.equal(sanitizeTypeName('Interface'), 'InterfaceType')
|
|
164
|
+
assert.equal(sanitizeTypeName('Type'), 'TypeType')
|
|
165
|
+
// lowercase versions also get the suffix
|
|
166
|
+
assert.equal(sanitizeTypeName('object'), 'objectType')
|
|
167
|
+
assert.equal(sanitizeTypeName('string'), 'stringType')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test('does not suffix names not in reserved set (e.g. Array)', () => {
|
|
171
|
+
// "array" is not in RESERVED_TYPE_NAMES
|
|
172
|
+
assert.equal(sanitizeTypeName('Array'), 'Array')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('strips special chars from type names', () => {
|
|
176
|
+
assert.equal(sanitizeTypeName('Foo.Bar'), 'FooBar')
|
|
177
|
+
assert.equal(sanitizeTypeName('my-type'), 'mytype')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('prefixes digit-leading type names with "n"', () => {
|
|
181
|
+
assert.equal(sanitizeTypeName('2DPoint'), 'n2DPoint')
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test('strips numeric separators', () => {
|
|
185
|
+
assert.equal(sanitizeTypeName('Limit1_000'), 'Limit1000')
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// schemaVarName
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
describe('schemaVarName', () => {
|
|
193
|
+
test('appends "Schema" suffix', () => {
|
|
194
|
+
assert.equal(schemaVarName('User'), 'UserSchema')
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
test('does not double-suffix "Schema"', () => {
|
|
198
|
+
assert.equal(schemaVarName('UserSchema'), 'UserSchema')
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
test('sanitizes reserved names before adding suffix', () => {
|
|
202
|
+
assert.equal(schemaVarName('Object'), 'ObjectTypeSchema')
|
|
203
|
+
})
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// null default on non-nullable schema
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
describe('default value handling', () => {
|
|
210
|
+
test('skips null default on non-nullable schema', () => {
|
|
211
|
+
const schema: OpenAPISchema = {
|
|
212
|
+
type: 'string',
|
|
213
|
+
default: null,
|
|
214
|
+
}
|
|
215
|
+
const code = schemaToZod(schema, ctx())
|
|
216
|
+
assert.ok(
|
|
217
|
+
!code.includes('.default('),
|
|
218
|
+
`should skip null default, got: ${code}`
|
|
219
|
+
)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test('allows null default on nullable schema', () => {
|
|
223
|
+
const schema: OpenAPISchema = {
|
|
224
|
+
type: 'string',
|
|
225
|
+
nullable: true,
|
|
226
|
+
default: null,
|
|
227
|
+
}
|
|
228
|
+
const code = schemaToZod(schema, ctx())
|
|
229
|
+
assert.ok(
|
|
230
|
+
code.includes('.default(null)'),
|
|
231
|
+
`expected .default(null), got: ${code}`
|
|
232
|
+
)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
test('coerces boolean default on string schema → string', () => {
|
|
236
|
+
const schema: OpenAPISchema = {
|
|
237
|
+
type: 'string',
|
|
238
|
+
default: false,
|
|
239
|
+
}
|
|
240
|
+
const code = schemaToZod(schema, ctx())
|
|
241
|
+
assert.ok(
|
|
242
|
+
code.includes('.default("false")'),
|
|
243
|
+
`expected .default("false"), got: ${code}`
|
|
244
|
+
)
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
test('coerces number default on string schema → string', () => {
|
|
248
|
+
const schema: OpenAPISchema = {
|
|
249
|
+
type: 'string',
|
|
250
|
+
default: 42,
|
|
251
|
+
}
|
|
252
|
+
const code = schemaToZod(schema, ctx())
|
|
253
|
+
assert.ok(
|
|
254
|
+
code.includes('.default("42")'),
|
|
255
|
+
`expected .default("42"), got: ${code}`
|
|
256
|
+
)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
test('coerces string default on boolean schema', () => {
|
|
260
|
+
const schema: OpenAPISchema = {
|
|
261
|
+
type: 'boolean',
|
|
262
|
+
default: 'true',
|
|
263
|
+
}
|
|
264
|
+
const code = schemaToZod(schema, ctx())
|
|
265
|
+
assert.ok(
|
|
266
|
+
code.includes('.default(true)'),
|
|
267
|
+
`expected .default(true), got: ${code}`
|
|
268
|
+
)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
test('coerces string default on number schema', () => {
|
|
272
|
+
const schema: OpenAPISchema = {
|
|
273
|
+
type: 'number',
|
|
274
|
+
default: '3.14',
|
|
275
|
+
}
|
|
276
|
+
const code = schemaToZod(schema, ctx())
|
|
277
|
+
assert.ok(
|
|
278
|
+
code.includes('.default(3.14)'),
|
|
279
|
+
`expected .default(3.14), got: ${code}`
|
|
280
|
+
)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
test('coerces boolean default on number schema', () => {
|
|
284
|
+
const schema: OpenAPISchema = {
|
|
285
|
+
type: 'number',
|
|
286
|
+
default: true,
|
|
287
|
+
}
|
|
288
|
+
const code = schemaToZod(schema, ctx())
|
|
289
|
+
assert.ok(
|
|
290
|
+
code.includes('.default(1)'),
|
|
291
|
+
`expected .default(1), got: ${code}`
|
|
292
|
+
)
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
test('skips non-array default on array schema', () => {
|
|
296
|
+
const schema: OpenAPISchema = {
|
|
297
|
+
type: 'array',
|
|
298
|
+
items: { type: 'string' },
|
|
299
|
+
default: 'not-an-array',
|
|
300
|
+
}
|
|
301
|
+
const code = schemaToZod(schema, ctx())
|
|
302
|
+
assert.ok(
|
|
303
|
+
!code.includes('.default('),
|
|
304
|
+
`should skip mismatched default, got: ${code}`
|
|
305
|
+
)
|
|
306
|
+
})
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
// ---------------------------------------------------------------------------
|
|
310
|
+
// Skip refinements on z.any(), z.unknown(), z.union(), z.lazy()
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
describe('refinement skipping', () => {
|
|
313
|
+
test('does not apply .min()/.max() to z.any() (no type, no properties)', () => {
|
|
314
|
+
// A schema with no type and no properties but with minimum → z.number()
|
|
315
|
+
// But a truly empty schema → z.unknown()
|
|
316
|
+
const schema: OpenAPISchema = {
|
|
317
|
+
minLength: 5,
|
|
318
|
+
maxLength: 100,
|
|
319
|
+
}
|
|
320
|
+
const code = schemaToZod(schema, ctx())
|
|
321
|
+
// No type, no properties, no min/max numbers → z.unknown()
|
|
322
|
+
assert.ok(
|
|
323
|
+
!code.includes('.min('),
|
|
324
|
+
`should not have .min() on z.unknown(), got: ${code}`
|
|
325
|
+
)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
test('does not apply refinements to z.union()', () => {
|
|
329
|
+
const schema: OpenAPISchema = {
|
|
330
|
+
oneOf: [{ type: 'string' }, { type: 'number' }],
|
|
331
|
+
minLength: 1,
|
|
332
|
+
}
|
|
333
|
+
const code = schemaToZod(schema, ctx())
|
|
334
|
+
assert.ok(code.startsWith('z.union('))
|
|
335
|
+
assert.ok(
|
|
336
|
+
!code.includes('.min('),
|
|
337
|
+
`should not have .min() on union, got: ${code}`
|
|
338
|
+
)
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
test('does not apply refinements to schema variable references', () => {
|
|
342
|
+
const refs = new Map([['User', 'UserSchema']])
|
|
343
|
+
const schema: OpenAPISchema = {
|
|
344
|
+
$ref: '#/components/schemas/User',
|
|
345
|
+
minLength: 1, // should be ignored
|
|
346
|
+
}
|
|
347
|
+
const c = ctx(refs)
|
|
348
|
+
const code = schemaToZod(schema, c)
|
|
349
|
+
assert.equal(code, 'UserSchema')
|
|
350
|
+
})
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// Infer z.number() when minimum/maximum present but no type
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
describe('type inference from constraints', () => {
|
|
357
|
+
test('infers z.number() when minimum is present without type', () => {
|
|
358
|
+
const schema: OpenAPISchema = {
|
|
359
|
+
minimum: 0,
|
|
360
|
+
}
|
|
361
|
+
const code = schemaToZod(schema, ctx())
|
|
362
|
+
assert.ok(code.includes('z.number()'), `expected z.number(), got: ${code}`)
|
|
363
|
+
assert.ok(code.includes('.min(0)'))
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
test('infers z.number() when maximum is present without type', () => {
|
|
367
|
+
const schema: OpenAPISchema = {
|
|
368
|
+
maximum: 100,
|
|
369
|
+
}
|
|
370
|
+
const code = schemaToZod(schema, ctx())
|
|
371
|
+
assert.ok(code.includes('z.number()'))
|
|
372
|
+
assert.ok(code.includes('.max(100)'))
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
test('infers z.number() from exclusiveMinimum without type', () => {
|
|
376
|
+
const schema: OpenAPISchema = {
|
|
377
|
+
exclusiveMinimum: 0,
|
|
378
|
+
}
|
|
379
|
+
const code = schemaToZod(schema, ctx())
|
|
380
|
+
assert.ok(code.includes('z.number()'))
|
|
381
|
+
assert.ok(code.includes('.gt(0)'))
|
|
382
|
+
})
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
// ---------------------------------------------------------------------------
|
|
386
|
+
// Depth limit — deeply nested schemas → z.any()
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
describe('depth limit', () => {
|
|
389
|
+
test('returns z.any() for schemas nested deeper than 10 levels', () => {
|
|
390
|
+
// Build a deeply nested object schema (12 levels)
|
|
391
|
+
let inner: OpenAPISchema = { type: 'string' }
|
|
392
|
+
for (let i = 0; i < 12; i++) {
|
|
393
|
+
inner = {
|
|
394
|
+
type: 'object',
|
|
395
|
+
properties: { nested: inner },
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const code = schemaToZod(inner, ctx())
|
|
399
|
+
// The innermost levels should collapse to z.any()
|
|
400
|
+
assert.ok(
|
|
401
|
+
code.includes('z.any()'),
|
|
402
|
+
`expected z.any() in deeply nested, got: ${code}`
|
|
403
|
+
)
|
|
404
|
+
})
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// Cycle detection in allOf/object recursion → z.any()
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
describe('cycle detection', () => {
|
|
411
|
+
test('returns z.any() for cyclically self-referencing schema', () => {
|
|
412
|
+
const schema: any = {
|
|
413
|
+
type: 'object',
|
|
414
|
+
properties: {},
|
|
415
|
+
}
|
|
416
|
+
// Create a cycle: schema.properties.self = schema
|
|
417
|
+
schema.properties.self = schema
|
|
418
|
+
const code = schemaToZod(schema, ctx())
|
|
419
|
+
// Should not stack overflow, and inner self should be z.any()
|
|
420
|
+
assert.ok(
|
|
421
|
+
code.includes('z.any()'),
|
|
422
|
+
`expected z.any() for cycle, got: ${code}`
|
|
423
|
+
)
|
|
424
|
+
})
|
|
425
|
+
|
|
426
|
+
test('handles cycle in allOf without stack overflow', () => {
|
|
427
|
+
const schemaA: any = {
|
|
428
|
+
type: 'object',
|
|
429
|
+
properties: { name: { type: 'string' } },
|
|
430
|
+
}
|
|
431
|
+
const schemaB: any = {
|
|
432
|
+
allOf: [schemaA],
|
|
433
|
+
}
|
|
434
|
+
// Create cycle: A references B
|
|
435
|
+
schemaA.properties.related = schemaB
|
|
436
|
+
// B references A via allOf
|
|
437
|
+
|
|
438
|
+
// This should not throw
|
|
439
|
+
const code = schemaToZod(schemaB, ctx())
|
|
440
|
+
assert.ok(typeof code === 'string')
|
|
441
|
+
assert.ok(
|
|
442
|
+
code.includes('z.any()'),
|
|
443
|
+
`expected z.any() breaking cycle, got: ${code}`
|
|
444
|
+
)
|
|
445
|
+
})
|
|
446
|
+
})
|
|
447
|
+
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
// Object with no type but has properties → treat as object
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
describe('implicit object type', () => {
|
|
452
|
+
test('treats schema with properties but no type as object', () => {
|
|
453
|
+
const schema: OpenAPISchema = {
|
|
454
|
+
properties: {
|
|
455
|
+
name: { type: 'string' },
|
|
456
|
+
age: { type: 'integer' },
|
|
457
|
+
},
|
|
458
|
+
required: ['name'],
|
|
459
|
+
}
|
|
460
|
+
const code = schemaToZod(schema, ctx())
|
|
461
|
+
assert.ok(code.includes('z.object('))
|
|
462
|
+
assert.ok(code.includes('z.string()'))
|
|
463
|
+
assert.ok(code.includes('z.number().int()'))
|
|
464
|
+
})
|
|
465
|
+
})
|
|
466
|
+
|
|
467
|
+
// ---------------------------------------------------------------------------
|
|
468
|
+
// String format handling
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
describe('string format handling', () => {
|
|
471
|
+
test('uuid format → z.string().uuid()', () => {
|
|
472
|
+
const code = schemaToZod({ type: 'string', format: 'uuid' }, ctx())
|
|
473
|
+
assert.ok(code.includes('.uuid()'))
|
|
474
|
+
})
|
|
475
|
+
|
|
476
|
+
test('date-time format → z.string().datetime()', () => {
|
|
477
|
+
const code = schemaToZod({ type: 'string', format: 'date-time' }, ctx())
|
|
478
|
+
assert.ok(code.includes('.datetime()'))
|
|
479
|
+
})
|
|
480
|
+
|
|
481
|
+
test('email format → z.string().email()', () => {
|
|
482
|
+
const code = schemaToZod({ type: 'string', format: 'email' }, ctx())
|
|
483
|
+
assert.ok(code.includes('.email()'))
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
test('uri format → z.string().url()', () => {
|
|
487
|
+
const code = schemaToZod({ type: 'string', format: 'uri' }, ctx())
|
|
488
|
+
assert.ok(code.includes('.url()'))
|
|
489
|
+
})
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
// ---------------------------------------------------------------------------
|
|
493
|
+
// $ref resolution
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
describe('$ref handling', () => {
|
|
496
|
+
test('replaces $ref with schema variable name', () => {
|
|
497
|
+
const refs = new Map([['Pet', 'PetSchema']])
|
|
498
|
+
const schema: OpenAPISchema = {
|
|
499
|
+
$ref: '#/components/schemas/Pet',
|
|
500
|
+
}
|
|
501
|
+
const c = ctx(refs)
|
|
502
|
+
const code = schemaToZod(schema, c)
|
|
503
|
+
assert.equal(code, 'PetSchema')
|
|
504
|
+
assert.ok(c.usedRefs.has('Pet'))
|
|
505
|
+
})
|
|
506
|
+
|
|
507
|
+
test('falls back to z.unknown() for unresolvable $ref', () => {
|
|
508
|
+
const schema: OpenAPISchema = {
|
|
509
|
+
$ref: '#/components/schemas/Unknown',
|
|
510
|
+
}
|
|
511
|
+
const code = schemaToZod(schema, ctx())
|
|
512
|
+
assert.equal(code, 'z.unknown()')
|
|
513
|
+
})
|
|
514
|
+
|
|
515
|
+
test('detects resolved $ref by object identity', () => {
|
|
516
|
+
const userSchema: OpenAPISchema = {
|
|
517
|
+
type: 'object',
|
|
518
|
+
properties: { id: { type: 'string' } },
|
|
519
|
+
}
|
|
520
|
+
const refs = new Map([['User', 'UserSchema']])
|
|
521
|
+
const identityMap = new Map<object, string>([[userSchema, 'User']])
|
|
522
|
+
const c = ctx(refs, identityMap)
|
|
523
|
+
|
|
524
|
+
// A schema that IS the same object as the component schema
|
|
525
|
+
const code = schemaToZod(userSchema, c)
|
|
526
|
+
assert.equal(code, 'UserSchema')
|
|
527
|
+
})
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
// Nullable and optional chaining order
|
|
532
|
+
// ---------------------------------------------------------------------------
|
|
533
|
+
describe('nullable and optional', () => {
|
|
534
|
+
test('chains .nullable().optional() in correct order', () => {
|
|
535
|
+
const schema: OpenAPISchema = {
|
|
536
|
+
type: 'string',
|
|
537
|
+
nullable: true,
|
|
538
|
+
}
|
|
539
|
+
const code = schemaToZod(schema, ctx(), { optional: true })
|
|
540
|
+
const nullableIdx = code.indexOf('.nullable()')
|
|
541
|
+
const optionalIdx = code.indexOf('.optional()')
|
|
542
|
+
assert.ok(
|
|
543
|
+
nullableIdx < optionalIdx,
|
|
544
|
+
`nullable should come before optional: ${code}`
|
|
545
|
+
)
|
|
546
|
+
})
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
// ---------------------------------------------------------------------------
|
|
550
|
+
// Array and record schemas
|
|
551
|
+
// ---------------------------------------------------------------------------
|
|
552
|
+
describe('array and record', () => {
|
|
553
|
+
test('array with items → z.array(inner)', () => {
|
|
554
|
+
const schema: OpenAPISchema = {
|
|
555
|
+
type: 'array',
|
|
556
|
+
items: { type: 'string' },
|
|
557
|
+
}
|
|
558
|
+
const code = schemaToZod(schema, ctx())
|
|
559
|
+
assert.equal(code, 'z.array(z.string())')
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
test('array without items → z.array(z.unknown())', () => {
|
|
563
|
+
const schema: OpenAPISchema = {
|
|
564
|
+
type: 'array',
|
|
565
|
+
}
|
|
566
|
+
const code = schemaToZod(schema, ctx())
|
|
567
|
+
assert.equal(code, 'z.array(z.unknown())')
|
|
568
|
+
})
|
|
569
|
+
|
|
570
|
+
test('object with no properties but additionalProperties → z.record()', () => {
|
|
571
|
+
const schema: OpenAPISchema = {
|
|
572
|
+
type: 'object',
|
|
573
|
+
additionalProperties: { type: 'number' },
|
|
574
|
+
}
|
|
575
|
+
const code = schemaToZod(schema, ctx())
|
|
576
|
+
assert.ok(code.includes('z.record(z.string(), z.number())'))
|
|
577
|
+
})
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
// ---------------------------------------------------------------------------
|
|
581
|
+
// allOf merging
|
|
582
|
+
// ---------------------------------------------------------------------------
|
|
583
|
+
describe('allOf handling', () => {
|
|
584
|
+
test('merges properties from multiple allOf schemas', () => {
|
|
585
|
+
const schema: OpenAPISchema = {
|
|
586
|
+
allOf: [
|
|
587
|
+
{
|
|
588
|
+
type: 'object',
|
|
589
|
+
properties: { name: { type: 'string' } },
|
|
590
|
+
required: ['name'],
|
|
591
|
+
},
|
|
592
|
+
{
|
|
593
|
+
type: 'object',
|
|
594
|
+
properties: { age: { type: 'integer' } },
|
|
595
|
+
},
|
|
596
|
+
],
|
|
597
|
+
}
|
|
598
|
+
const code = schemaToZod(schema, ctx())
|
|
599
|
+
assert.ok(code.includes('z.object('))
|
|
600
|
+
assert.ok(code.includes('name:'))
|
|
601
|
+
assert.ok(code.includes('age:'))
|
|
602
|
+
})
|
|
603
|
+
|
|
604
|
+
test('uses .merge() for allOf with only $refs', () => {
|
|
605
|
+
const refs = new Map([
|
|
606
|
+
['Base', 'BaseSchema'],
|
|
607
|
+
['Extra', 'ExtraSchema'],
|
|
608
|
+
])
|
|
609
|
+
const schema: OpenAPISchema = {
|
|
610
|
+
allOf: [
|
|
611
|
+
{ $ref: '#/components/schemas/Base' },
|
|
612
|
+
{ $ref: '#/components/schemas/Extra' },
|
|
613
|
+
],
|
|
614
|
+
}
|
|
615
|
+
const code = schemaToZod(schema, ctx(refs))
|
|
616
|
+
assert.ok(
|
|
617
|
+
code.includes('BaseSchema.merge(ExtraSchema)'),
|
|
618
|
+
`expected merge, got: ${code}`
|
|
619
|
+
)
|
|
620
|
+
})
|
|
621
|
+
})
|
|
622
|
+
|
|
623
|
+
// ---------------------------------------------------------------------------
|
|
624
|
+
// Array refinements (minItems / maxItems)
|
|
625
|
+
// ---------------------------------------------------------------------------
|
|
626
|
+
describe('array refinements', () => {
|
|
627
|
+
test('applies .min() and .max() for minItems/maxItems', () => {
|
|
628
|
+
const schema: OpenAPISchema = {
|
|
629
|
+
type: 'array',
|
|
630
|
+
items: { type: 'string' },
|
|
631
|
+
minItems: 1,
|
|
632
|
+
maxItems: 50,
|
|
633
|
+
}
|
|
634
|
+
const code = schemaToZod(schema, ctx())
|
|
635
|
+
assert.ok(code.includes('.min(1)'))
|
|
636
|
+
assert.ok(code.includes('.max(50)'))
|
|
637
|
+
})
|
|
638
|
+
})
|
|
639
|
+
|
|
640
|
+
// ---------------------------------------------------------------------------
|
|
641
|
+
// String refinements (minLength / maxLength / pattern)
|
|
642
|
+
// ---------------------------------------------------------------------------
|
|
643
|
+
describe('string refinements', () => {
|
|
644
|
+
test('applies .min() and .max() for minLength/maxLength', () => {
|
|
645
|
+
const schema: OpenAPISchema = {
|
|
646
|
+
type: 'string',
|
|
647
|
+
minLength: 3,
|
|
648
|
+
maxLength: 255,
|
|
649
|
+
}
|
|
650
|
+
const code = schemaToZod(schema, ctx())
|
|
651
|
+
assert.ok(code.includes('.min(3)'))
|
|
652
|
+
assert.ok(code.includes('.max(255)'))
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
test('applies .regex() for pattern', () => {
|
|
656
|
+
const schema: OpenAPISchema = {
|
|
657
|
+
type: 'string',
|
|
658
|
+
pattern: '^[a-z]+$',
|
|
659
|
+
}
|
|
660
|
+
const code = schemaToZod(schema, ctx())
|
|
661
|
+
assert.ok(code.includes('.regex('))
|
|
662
|
+
assert.ok(code.includes('^[a-z]+$'))
|
|
663
|
+
})
|
|
664
|
+
})
|