@amritk/generate-parsers 0.2.1 → 0.3.0
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/dist/generators/build-schema.d.ts +5 -1
- package/dist/generators/generate-files.d.ts +15 -2
- package/dist/helpers/collect-helpers.d.ts +16 -2
- package/dist/index.js +64 -19
- package/package.json +3 -2
- package/src/generators/build-schema.test.ts +830 -0
- package/src/generators/build-schema.ts +280 -0
- package/src/generators/generate-default-from-pattern.test.ts +81 -0
- package/src/generators/generate-default-from-pattern.ts +39 -0
- package/src/generators/generate-discriminated-union-validation.test.ts +84 -0
- package/src/generators/generate-discriminated-union-validation.ts +54 -0
- package/src/generators/generate-enum-check.test.ts +45 -0
- package/src/generators/generate-enum-check.ts +8 -0
- package/src/generators/generate-files.test.ts +435 -0
- package/src/generators/generate-files.ts +136 -0
- package/src/generators/generate-non-discriminated-union-validation.test.ts +74 -0
- package/src/generators/generate-non-discriminated-union-validation.ts +40 -0
- package/src/generators/generate-parser-function.test.ts +1798 -0
- package/src/generators/generate-parser-function.ts +1319 -0
- package/src/generators/generate-schema-checks.test.ts +322 -0
- package/src/generators/generate-schema-checks.ts +114 -0
- package/src/generators/generate-strict-assertion.ts +210 -0
- package/src/generators/generate-validation-expression.test.ts +794 -0
- package/src/generators/generate-validation-expression.ts +286 -0
- package/src/helpers/apply-schema-extensions.test.ts +194 -0
- package/src/helpers/apply-schema-extensions.ts +63 -0
- package/src/helpers/collect-helpers.test.ts +103 -0
- package/src/helpers/collect-helpers.ts +64 -0
- package/src/helpers/collect-imports.test.ts +587 -0
- package/src/helpers/collect-imports.ts +268 -0
- package/src/helpers/find-discriminator.test.ts +88 -0
- package/src/helpers/find-discriminator.ts +39 -0
- package/src/helpers/get-default-value.test.ts +135 -0
- package/src/helpers/get-default-value.ts +89 -0
- package/src/helpers/get-discriminator-value.test.ts +83 -0
- package/src/helpers/get-discriminator-value.ts +21 -0
- package/src/index.ts +1 -0
- package/src/types/schema-extensions.ts +35 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { generateFile } from './generate-files'
|
|
4
|
+
|
|
5
|
+
describe('generate-files', () => {
|
|
6
|
+
it('generates combined import for $ref property', () => {
|
|
7
|
+
const schema = {
|
|
8
|
+
type: 'object',
|
|
9
|
+
properties: {
|
|
10
|
+
contact: { $ref: '#/$defs/contact' },
|
|
11
|
+
},
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const { content: result } = generateFile(schema, 'Document')
|
|
15
|
+
|
|
16
|
+
expect(result).toContain(
|
|
17
|
+
"import { type ContactObject, parseContactObject, validateContactObjectShape } from './contact';",
|
|
18
|
+
)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('calls imported parser for optional $ref property', () => {
|
|
22
|
+
const schema = {
|
|
23
|
+
type: 'object',
|
|
24
|
+
properties: {
|
|
25
|
+
contact: { $ref: '#/$defs/contact' },
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const { content: result } = generateFile(schema, 'Document')
|
|
30
|
+
|
|
31
|
+
// With local variable caching, optional $ref properties use the cached variable
|
|
32
|
+
expect(result).toContain('_contact !== undefined && { contact: parseContactObject(_contact) }')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('calls imported parser for required $ref property', () => {
|
|
36
|
+
const schema = {
|
|
37
|
+
type: 'object',
|
|
38
|
+
properties: {
|
|
39
|
+
contact: { $ref: '#/$defs/contact' },
|
|
40
|
+
},
|
|
41
|
+
required: ['contact'],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const { content: result } = generateFile(schema, 'Document')
|
|
45
|
+
|
|
46
|
+
// With local variable caching, required $ref properties use the cached variable
|
|
47
|
+
expect(result).toContain('contact: parseContactObject(_contact),')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('generates combined imports for multiple $ref properties', () => {
|
|
51
|
+
const schema = {
|
|
52
|
+
type: 'object',
|
|
53
|
+
properties: {
|
|
54
|
+
contact: { $ref: '#/$defs/contact' },
|
|
55
|
+
server: { $ref: '#/$defs/server' },
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const { content: result } = generateFile(schema, 'Document')
|
|
60
|
+
|
|
61
|
+
expect(result).toContain(
|
|
62
|
+
"import { type ContactObject, parseContactObject, validateContactObjectShape } from './contact';",
|
|
63
|
+
)
|
|
64
|
+
expect(result).toContain(
|
|
65
|
+
"import { type ServerObject, parseServerObject, validateServerObjectShape } from './server';",
|
|
66
|
+
)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('generates correct parser name for multi-word $ref', () => {
|
|
70
|
+
const schema = {
|
|
71
|
+
type: 'object',
|
|
72
|
+
properties: {
|
|
73
|
+
externalDoc: { $ref: '#/$defs/external-documentation' },
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const { content: result } = generateFile(schema, 'Document')
|
|
78
|
+
|
|
79
|
+
expect(result).toContain(
|
|
80
|
+
"import { type ExternalDocumentationObject, parseExternalDocumentationObject, validateExternalDocumentationObjectShape } from './external-documentation';",
|
|
81
|
+
)
|
|
82
|
+
expect(result).toContain('parseExternalDocumentationObject(_externalDoc)')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('does not generate ref imports when no $ref properties exist', () => {
|
|
86
|
+
const schema = {
|
|
87
|
+
type: 'object',
|
|
88
|
+
properties: {
|
|
89
|
+
name: { type: 'string' },
|
|
90
|
+
age: { type: 'number' },
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const { content: result } = generateFile(schema, 'Simple')
|
|
95
|
+
|
|
96
|
+
// Should still import isObject helper
|
|
97
|
+
expect(result).toContain("import { isObject } from '@amritk/helpers/is-object';")
|
|
98
|
+
// But should not import any ref types (check for type imports specifically)
|
|
99
|
+
expect(result).not.toContain('import { type')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('still validates non-ref properties inline', () => {
|
|
103
|
+
const schema = {
|
|
104
|
+
type: 'object',
|
|
105
|
+
properties: {
|
|
106
|
+
name: { type: 'string' },
|
|
107
|
+
contact: { $ref: '#/$defs/contact' },
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const { content: result } = generateFile(schema, 'Document')
|
|
112
|
+
|
|
113
|
+
// Non-ref property should have inline validation against the cached local
|
|
114
|
+
expect(result).toContain('typeof _name === "string"')
|
|
115
|
+
// Ref property should call imported parser using cached variable
|
|
116
|
+
expect(result).toContain('parseContactObject(_contact)')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('generates a correct import for -or-reference style $ref names', () => {
|
|
120
|
+
// Without stripping, 'callbacks-or-reference' maps to the filename 'callbacks-or-reference'
|
|
121
|
+
const schema = {
|
|
122
|
+
type: 'object',
|
|
123
|
+
properties: {
|
|
124
|
+
callbacks: { $ref: '#/$defs/callbacks-or-reference' },
|
|
125
|
+
},
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const { content: result } = generateFile(schema, 'Document')
|
|
129
|
+
|
|
130
|
+
expect(result).toContain(
|
|
131
|
+
"import { type CallbacksOrReferenceObject, parseCallbacksOrReferenceObject, validateCallbacksOrReferenceObjectShape } from './callbacks-or-reference';",
|
|
132
|
+
)
|
|
133
|
+
expect(result).toContain('parseCallbacksOrReferenceObject(_callbacks)')
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('generates arrow function parser', () => {
|
|
137
|
+
const schema = {
|
|
138
|
+
type: 'object',
|
|
139
|
+
properties: {
|
|
140
|
+
name: { type: 'string' },
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const { content: result } = generateFile(schema, 'Simple')
|
|
145
|
+
|
|
146
|
+
expect(result).toContain('export const parseSimple = (input: unknown): Simple =>')
|
|
147
|
+
expect(result).not.toContain('function parseSimple')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('maps array items through imported parser for optional array with $ref items', () => {
|
|
151
|
+
const schema = {
|
|
152
|
+
type: 'object',
|
|
153
|
+
properties: {
|
|
154
|
+
servers: {
|
|
155
|
+
type: 'array',
|
|
156
|
+
items: { $ref: '#/$defs/server' },
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const { content: result } = generateFile(schema, 'Document')
|
|
162
|
+
|
|
163
|
+
expect(result).toContain(
|
|
164
|
+
"import { type ServerObject, parseServerObject, validateServerObjectShape } from './server';",
|
|
165
|
+
)
|
|
166
|
+
// With local variable caching, array validators use the cached variable
|
|
167
|
+
expect(result).toContain('validateArray(_servers, parseServerObject)')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('maps array items through imported parser for required array with $ref items', () => {
|
|
171
|
+
const schema = {
|
|
172
|
+
type: 'object',
|
|
173
|
+
properties: {
|
|
174
|
+
servers: {
|
|
175
|
+
type: 'array',
|
|
176
|
+
items: { $ref: '#/$defs/server' },
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
required: ['servers'],
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const { content: result } = generateFile(schema, 'Document')
|
|
183
|
+
|
|
184
|
+
// Check for required imports
|
|
185
|
+
expect(result).toContain(
|
|
186
|
+
"import { type ServerObject, parseServerObject, validateServerObjectShape } from './server';",
|
|
187
|
+
)
|
|
188
|
+
expect(result).toContain("import { validateArray } from '@amritk/helpers/validate-array';")
|
|
189
|
+
expect(result).toContain("import { isObject } from '@amritk/helpers/is-object';")
|
|
190
|
+
|
|
191
|
+
// Check for required array validation using cached variable
|
|
192
|
+
expect(result).toContain('servers: validateArray(_servers, parseServerObject),')
|
|
193
|
+
|
|
194
|
+
// Should not use spread operator for required fields
|
|
195
|
+
expect(result).not.toContain('...(_servers')
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('generates type and parser for schema with extension properties', () => {
|
|
199
|
+
const schema = {
|
|
200
|
+
type: 'object',
|
|
201
|
+
properties: {
|
|
202
|
+
name: { type: 'string' },
|
|
203
|
+
'x-enabled': { type: 'boolean' },
|
|
204
|
+
'x-internal': { type: 'boolean' },
|
|
205
|
+
},
|
|
206
|
+
required: ['name'],
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const { content: result } = generateFile(schema, 'Parameter')
|
|
210
|
+
|
|
211
|
+
// Type should include extension properties as optional
|
|
212
|
+
expect(result).toContain("'x-enabled'?: boolean")
|
|
213
|
+
expect(result).toContain("'x-internal'?: boolean")
|
|
214
|
+
// Parser should validate extension properties
|
|
215
|
+
expect(result).toContain('x_enabled')
|
|
216
|
+
expect(result).toContain('x_internal')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('generates type and parser for schema with complex extension property', () => {
|
|
220
|
+
const schema = {
|
|
221
|
+
type: 'object',
|
|
222
|
+
properties: {
|
|
223
|
+
summary: { type: 'string' },
|
|
224
|
+
'x-codegen': {
|
|
225
|
+
type: 'object',
|
|
226
|
+
properties: {
|
|
227
|
+
methodName: { type: 'string' },
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const { content: result } = generateFile(schema, 'Operation')
|
|
234
|
+
|
|
235
|
+
// Type should include the complex extension
|
|
236
|
+
expect(result).toContain("'x-codegen'")
|
|
237
|
+
expect(result).toContain('methodName')
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
it('omits parser function in types-only mode', () => {
|
|
241
|
+
const schema = {
|
|
242
|
+
type: 'object',
|
|
243
|
+
properties: {
|
|
244
|
+
name: { type: 'string' },
|
|
245
|
+
},
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const { content: result } = generateFile(schema, 'Simple', { typesOnly: true })
|
|
249
|
+
|
|
250
|
+
expect(result).toContain('export type Simple')
|
|
251
|
+
expect(result).not.toContain('export const parseSimple')
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('uses type-only imports for $ref properties in types-only mode', () => {
|
|
255
|
+
const schema = {
|
|
256
|
+
type: 'object',
|
|
257
|
+
properties: {
|
|
258
|
+
contact: { $ref: '#/$defs/contact' },
|
|
259
|
+
},
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const { content: result } = generateFile(schema, 'Document', { typesOnly: true })
|
|
263
|
+
|
|
264
|
+
expect(result).toContain("import type { ContactObject } from './contact';")
|
|
265
|
+
expect(result).not.toContain('parseContactObject')
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
it('does not include runtime helper imports in types-only mode', () => {
|
|
269
|
+
const schema = {
|
|
270
|
+
type: 'object',
|
|
271
|
+
properties: {
|
|
272
|
+
name: { type: 'string' },
|
|
273
|
+
},
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const { content: result } = generateFile(schema, 'Simple', { typesOnly: true })
|
|
277
|
+
|
|
278
|
+
// No parser helpers should be imported since there is no parser function
|
|
279
|
+
expect(result).not.toContain('import { isObject }')
|
|
280
|
+
expect(result).not.toContain('import { validateArray }')
|
|
281
|
+
expect(result).not.toContain('import { validateRecord }')
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('still generates the type definition in types-only mode', () => {
|
|
285
|
+
const schema = {
|
|
286
|
+
type: 'object',
|
|
287
|
+
properties: {
|
|
288
|
+
name: { type: 'string' },
|
|
289
|
+
age: { type: 'number' },
|
|
290
|
+
},
|
|
291
|
+
required: ['name'],
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const { content: result } = generateFile(schema, 'User', { typesOnly: true })
|
|
295
|
+
|
|
296
|
+
expect(result).toContain('export type User')
|
|
297
|
+
expect(result).toContain('name: string')
|
|
298
|
+
expect(result).toContain('age?: number')
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('generates type-only import for array items $ref in types-only mode', () => {
|
|
302
|
+
const schema = {
|
|
303
|
+
type: 'object',
|
|
304
|
+
properties: {
|
|
305
|
+
servers: {
|
|
306
|
+
type: 'array',
|
|
307
|
+
items: { $ref: '#/$defs/server' },
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const { content: result } = generateFile(schema, 'Document', { typesOnly: true })
|
|
313
|
+
|
|
314
|
+
expect(result).toContain("import type { ServerObject } from './server';")
|
|
315
|
+
expect(result).not.toContain('parseServerObject')
|
|
316
|
+
expect(result).not.toContain('validateArray')
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it('generates type-only import for additionalProperties $ref in types-only mode', () => {
|
|
320
|
+
const schema = {
|
|
321
|
+
type: 'object',
|
|
322
|
+
additionalProperties: { $ref: '#/$defs/path-item' },
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const { content: result } = generateFile(schema, 'Paths', { typesOnly: true })
|
|
326
|
+
|
|
327
|
+
expect(result).toContain("import type { PathItemObject } from './path-item';")
|
|
328
|
+
expect(result).not.toContain('parsePathItemObject')
|
|
329
|
+
expect(result).not.toContain('validateRecord')
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
it('generates type-only imports for oneOf $ref variants in types-only mode', () => {
|
|
333
|
+
const schema = {
|
|
334
|
+
oneOf: [{ $ref: '#/$defs/cat' }, { $ref: '#/$defs/dog' }],
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const { content: result } = generateFile(schema, 'Pet', { typesOnly: true })
|
|
338
|
+
|
|
339
|
+
expect(result).toContain("import type { CatObject } from './cat';")
|
|
340
|
+
expect(result).toContain("import type { DogObject } from './dog';")
|
|
341
|
+
expect(result).not.toContain('parseCatObject')
|
|
342
|
+
expect(result).not.toContain('parseDogObject')
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('generates type-only imports for anyOf $ref variants in types-only mode', () => {
|
|
346
|
+
const schema = {
|
|
347
|
+
type: 'object',
|
|
348
|
+
properties: {
|
|
349
|
+
value: {
|
|
350
|
+
anyOf: [{ $ref: '#/$defs/string-value' }, { $ref: '#/$defs/number-value' }],
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const { content: result } = generateFile(schema, 'Wrapper', { typesOnly: true })
|
|
356
|
+
|
|
357
|
+
expect(result).toContain("import type { StringValueObject } from './string-value';")
|
|
358
|
+
expect(result).toContain("import type { NumberValueObject } from './number-value';")
|
|
359
|
+
expect(result).not.toContain('parseStringValueObject')
|
|
360
|
+
expect(result).not.toContain('parseNumberValueObject')
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
it('does not change default behavior when typesOnly is false', () => {
|
|
364
|
+
const schema = {
|
|
365
|
+
type: 'object',
|
|
366
|
+
properties: {
|
|
367
|
+
contact: { $ref: '#/$defs/contact' },
|
|
368
|
+
},
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const { content: defaultResult } = generateFile(schema, 'Document')
|
|
372
|
+
const { content: explicitFalseResult } = generateFile(schema, 'Document', { typesOnly: false })
|
|
373
|
+
|
|
374
|
+
expect(defaultResult).toBe(explicitFalseResult)
|
|
375
|
+
expect(defaultResult).toContain(
|
|
376
|
+
"import { type ContactObject, parseContactObject, validateContactObjectShape } from './contact';",
|
|
377
|
+
)
|
|
378
|
+
expect(defaultResult).toContain('export const parseDocument')
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
it('does not generate a self-import for recursive schemas in types-only mode', () => {
|
|
382
|
+
// Mirrors the 3.2.0 encoding schema: EncodingObject has properties itemEncoding, prefixEncoding,
|
|
383
|
+
// and encoding that all $ref back to #/$defs/encoding. The generated encoding.ts must not
|
|
384
|
+
// import EncodingObject from itself.
|
|
385
|
+
const schema = {
|
|
386
|
+
type: 'object',
|
|
387
|
+
properties: {
|
|
388
|
+
itemEncoding: { $ref: '#/$defs/encoding' },
|
|
389
|
+
prefixEncoding: {
|
|
390
|
+
type: 'array',
|
|
391
|
+
items: { $ref: '#/$defs/encoding' },
|
|
392
|
+
},
|
|
393
|
+
encoding: {
|
|
394
|
+
type: 'object',
|
|
395
|
+
additionalProperties: { $ref: '#/$defs/encoding' },
|
|
396
|
+
},
|
|
397
|
+
headers: {
|
|
398
|
+
type: 'object',
|
|
399
|
+
additionalProperties: { $ref: '#/$defs/header-or-reference' },
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
const { content: result } = generateFile(schema, 'EncodingObject', {
|
|
405
|
+
typesOnly: true,
|
|
406
|
+
selfRef: '#/$defs/encoding',
|
|
407
|
+
})
|
|
408
|
+
|
|
409
|
+
expect(result).not.toContain("import type { EncodingObject } from './encoding';")
|
|
410
|
+
expect(result).toContain("import type { HeaderOrReferenceObject } from './header-or-reference';")
|
|
411
|
+
expect(result).toContain('export type EncodingObject')
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
it('does not generate a self-import for recursive schemas in full parser mode', () => {
|
|
415
|
+
const schema = {
|
|
416
|
+
type: 'object',
|
|
417
|
+
properties: {
|
|
418
|
+
itemEncoding: { $ref: '#/$defs/encoding' },
|
|
419
|
+
headers: {
|
|
420
|
+
type: 'object',
|
|
421
|
+
additionalProperties: { $ref: '#/$defs/header-or-reference' },
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const { content: result } = generateFile(schema, 'EncodingObject', { selfRef: '#/$defs/encoding' })
|
|
427
|
+
|
|
428
|
+
expect(result).not.toContain(
|
|
429
|
+
"import { type EncodingObject, parseEncodingObject, validateEncodingObjectShape } from './encoding';",
|
|
430
|
+
)
|
|
431
|
+
expect(result).toContain(
|
|
432
|
+
"import { type HeaderOrReferenceObject, parseHeaderOrReferenceObject, validateHeaderOrReferenceObjectShape } from './header-or-reference';",
|
|
433
|
+
)
|
|
434
|
+
})
|
|
435
|
+
})
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { generateTypeDefinition } from '@amritk/helpers/generate-type-definition'
|
|
2
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
|
|
3
|
+
import {
|
|
4
|
+
type CollectedHelpers,
|
|
5
|
+
collectHelpers,
|
|
6
|
+
type HelpersMode,
|
|
7
|
+
type RuntimeHelperName,
|
|
8
|
+
} from '#helpers/collect-helpers'
|
|
9
|
+
import { collectImports } from '#helpers/collect-imports'
|
|
10
|
+
|
|
11
|
+
import { generateParserFunction, generateShapeValidator } from './generate-parser-function'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Options for controlling what gets generated in a file.
|
|
15
|
+
*/
|
|
16
|
+
type GenerateFileOptions = {
|
|
17
|
+
/**
|
|
18
|
+
* When true, only emit the TypeScript type definition and skip the parser function.
|
|
19
|
+
* Imports will be type-only as well, since there are no parsers to call.
|
|
20
|
+
*/
|
|
21
|
+
readonly typesOnly?: boolean
|
|
22
|
+
/**
|
|
23
|
+
* The $ref path of the schema being generated (e.g. `#/$defs/encoding`).
|
|
24
|
+
* When provided, any $ref that resolves to the same filename is excluded from
|
|
25
|
+
* the import list, preventing a file from importing itself.
|
|
26
|
+
*/
|
|
27
|
+
readonly selfRef?: string
|
|
28
|
+
/**
|
|
29
|
+
* The root schema document. When provided, URI refs that cannot be resolved
|
|
30
|
+
* within the root schema's $defs are excluded from the import list.
|
|
31
|
+
*/
|
|
32
|
+
readonly rootSchema?: Record<string, unknown>
|
|
33
|
+
/**
|
|
34
|
+
* When true, the generated parser emits a console.warn for every input key
|
|
35
|
+
* that is not declared in the schema's properties.
|
|
36
|
+
*/
|
|
37
|
+
readonly logWarnings?: boolean
|
|
38
|
+
/**
|
|
39
|
+
* When true, the generated parser throws on type/shape mismatches instead
|
|
40
|
+
* of coercing invalid input to default values.
|
|
41
|
+
*/
|
|
42
|
+
readonly strict?: boolean
|
|
43
|
+
/**
|
|
44
|
+
* Controls how runtime helpers are referenced.
|
|
45
|
+
* - `'package'` (default): emit imports from `@amritk/helpers/...`.
|
|
46
|
+
* - `'embedded'`: emit imports from `./_helpers/...` so the helper sources can
|
|
47
|
+
* be shipped alongside the generated files.
|
|
48
|
+
*/
|
|
49
|
+
readonly helpersMode?: HelpersMode
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Result of generating a single parser file. */
|
|
53
|
+
export type GeneratedFileContent = {
|
|
54
|
+
readonly content: string
|
|
55
|
+
readonly usedHelpers: ReadonlySet<RuntimeHelperName>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Generates a complete TypeScript file from a JSON Schema.
|
|
60
|
+
*
|
|
61
|
+
* The generated file contains imports, type definition, and parser function.
|
|
62
|
+
* When typesOnly is true, only the type definition is emitted without any parser.
|
|
63
|
+
*
|
|
64
|
+
* @param schema - The JSON Schema to generate code from
|
|
65
|
+
* @param typeName - The name to use for the generated TypeScript type
|
|
66
|
+
* @param options - Optional settings to control what gets generated
|
|
67
|
+
* @returns The generated file content and the set of runtime helpers it references
|
|
68
|
+
*
|
|
69
|
+
* @example
|
|
70
|
+
* ```typescript
|
|
71
|
+
* const schema = {
|
|
72
|
+
* type: "object",
|
|
73
|
+
* properties: {
|
|
74
|
+
* name: { type: "string" },
|
|
75
|
+
* age: { type: "number" }
|
|
76
|
+
* },
|
|
77
|
+
* required: ["name"]
|
|
78
|
+
* };
|
|
79
|
+
*
|
|
80
|
+
* const result = generateFile(schema, "User");
|
|
81
|
+
* // result contains imports, type User, and parseUser function
|
|
82
|
+
*
|
|
83
|
+
* const typesOnly = generateFile(schema, "User", { typesOnly: true });
|
|
84
|
+
* // typesOnly contains only type-only imports and type User (no parser)
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
export const generateFile = (
|
|
88
|
+
schema: JSONSchema,
|
|
89
|
+
typeName: string,
|
|
90
|
+
options?: GenerateFileOptions,
|
|
91
|
+
): GeneratedFileContent => {
|
|
92
|
+
const typesOnly = options?.typesOnly === true
|
|
93
|
+
const selfRef = options?.selfRef
|
|
94
|
+
const rootSchema = options?.rootSchema
|
|
95
|
+
const helpersMode: HelpersMode = options?.helpersMode ?? 'package'
|
|
96
|
+
const typeDefinition = generateTypeDefinition(schema, typeName)
|
|
97
|
+
|
|
98
|
+
if (typesOnly) {
|
|
99
|
+
// In types-only mode, skip the parser function and use type-only imports
|
|
100
|
+
const imports = collectImports(schema, { typesOnly: true, selfRef, rootSchema })
|
|
101
|
+
let result = ''
|
|
102
|
+
|
|
103
|
+
if (imports.length > 0) {
|
|
104
|
+
result += imports[0]
|
|
105
|
+
for (let i = 1; i < imports.length; i++) {
|
|
106
|
+
result += '\n' + imports[i]
|
|
107
|
+
}
|
|
108
|
+
result += '\n\n'
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { content: result + typeDefinition, usedHelpers: new Set() }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const parserFunction = generateParserFunction(schema, typeName, {
|
|
115
|
+
useRefImports: true,
|
|
116
|
+
...(options?.logWarnings !== undefined ? { logWarnings: options.logWarnings } : {}),
|
|
117
|
+
...(options?.strict !== undefined ? { strict: options.strict } : {}),
|
|
118
|
+
})
|
|
119
|
+
const shapeValidator = generateShapeValidator(schema, typeName, true)
|
|
120
|
+
const combinedFunctions = `${shapeValidator}\n\n${parserFunction}`
|
|
121
|
+
const helpers: CollectedHelpers = collectHelpers(combinedFunctions, helpersMode)
|
|
122
|
+
const imports = [...collectImports(schema, { selfRef, rootSchema }), ...helpers.imports]
|
|
123
|
+
|
|
124
|
+
// Build file output using string concatenation instead of array join for performance
|
|
125
|
+
let result = ''
|
|
126
|
+
|
|
127
|
+
if (imports.length > 0) {
|
|
128
|
+
result += imports[0]
|
|
129
|
+
for (let i = 1; i < imports.length; i++) {
|
|
130
|
+
result += '\n' + imports[i]
|
|
131
|
+
}
|
|
132
|
+
result += '\n\n'
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { content: result + typeDefinition + '\n\n' + combinedFunctions, usedHelpers: helpers.used }
|
|
136
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { generateNonDiscriminatedUnionValidation } from './generate-non-discriminated-union-validation'
|
|
4
|
+
|
|
5
|
+
describe('generate-non-discriminated-union-validation', () => {
|
|
6
|
+
it('generates OR chain for multiple schema checks', () => {
|
|
7
|
+
const schemas = [{ type: 'string' as const }, { type: 'number' as const }]
|
|
8
|
+
const result = generateNonDiscriminatedUnionValidation('value', schemas, '""', true)
|
|
9
|
+
expect(result).toContain('typeof value === "string"')
|
|
10
|
+
expect(result).toContain('typeof value === "number"')
|
|
11
|
+
expect(result).toContain('||')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('returns fallback with nullish coalescing when required and no checks', () => {
|
|
15
|
+
const schemas = [{ description: 'no type' }]
|
|
16
|
+
const result = generateNonDiscriminatedUnionValidation('value', schemas, '""', true)
|
|
17
|
+
expect(result).toBe('value ?? ""')
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('returns accessor when not required and no checks', () => {
|
|
21
|
+
const schemas = [{ description: 'no type' }]
|
|
22
|
+
const result = generateNonDiscriminatedUnionValidation('value', schemas, '""', false)
|
|
23
|
+
expect(result).toBe('value')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('wraps valid check in ternary with default value when required', () => {
|
|
27
|
+
const schemas = [{ type: 'string' as const }]
|
|
28
|
+
const result = generateNonDiscriminatedUnionValidation('value', schemas, '""', true)
|
|
29
|
+
expect(result).toBe('((typeof value === "string")) ? value : ""')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('uses undefined as fallback when not required', () => {
|
|
33
|
+
const schemas = [{ type: 'string' as const }]
|
|
34
|
+
const result = generateNonDiscriminatedUnionValidation('value', schemas, '""', false)
|
|
35
|
+
expect(result).toBe('((typeof value === "string")) ? value : undefined')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('handles schemas with multiple constraints combined with &&', () => {
|
|
39
|
+
const schemas = [{ type: 'string' as const, minLength: 1, maxLength: 100 }]
|
|
40
|
+
const result = generateNonDiscriminatedUnionValidation('value', schemas, '""', true)
|
|
41
|
+
expect(result).toContain('typeof value === "string"')
|
|
42
|
+
expect(result).toContain('value.length >= 1')
|
|
43
|
+
expect(result).toContain('value.length <= 100')
|
|
44
|
+
expect(result).toContain('&&')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('handles empty schemas array when required', () => {
|
|
48
|
+
const result = generateNonDiscriminatedUnionValidation('value', [], '""', true)
|
|
49
|
+
expect(result).toBe('value ?? ""')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('handles empty schemas array when not required', () => {
|
|
53
|
+
const result = generateNonDiscriminatedUnionValidation('value', [], '""', false)
|
|
54
|
+
expect(result).toBe('value')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('generates multiple OR groups for different types', () => {
|
|
58
|
+
const schemas = [{ type: 'string' as const }, { type: 'number' as const }, { type: 'boolean' as const }]
|
|
59
|
+
const result = generateNonDiscriminatedUnionValidation('value', schemas, 'null', true)
|
|
60
|
+
expect(result).toContain('typeof value === "string"')
|
|
61
|
+
expect(result).toContain('typeof value === "number"')
|
|
62
|
+
expect(result).toContain('typeof value === "boolean"')
|
|
63
|
+
// Should use || between groups
|
|
64
|
+
const orCount = (result.match(/\|\|/g) || []).length
|
|
65
|
+
expect(orCount).toBe(2)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('uses the provided accessor throughout', () => {
|
|
69
|
+
const schemas = [{ type: 'string' as const }]
|
|
70
|
+
const result = generateNonDiscriminatedUnionValidation('input?.name', schemas, '""', true)
|
|
71
|
+
expect(result).toContain('typeof input?.name === "string"')
|
|
72
|
+
expect(result).toContain('input?.name :')
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
|
|
2
|
+
|
|
3
|
+
import { generateSchemaChecks } from './generate-schema-checks'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Generates a non-discriminated union validation expression.
|
|
7
|
+
* Tries each schema in order until one matches.
|
|
8
|
+
*/
|
|
9
|
+
export const generateNonDiscriminatedUnionValidation = (
|
|
10
|
+
accessor: string,
|
|
11
|
+
schemas: readonly JSONSchema[],
|
|
12
|
+
defaultValue: string,
|
|
13
|
+
isRequired: boolean,
|
|
14
|
+
): string => {
|
|
15
|
+
const cases: string[] = []
|
|
16
|
+
|
|
17
|
+
for (const schema of schemas) {
|
|
18
|
+
const checks = generateSchemaChecks(accessor, schema)
|
|
19
|
+
|
|
20
|
+
if (checks.length > 0) {
|
|
21
|
+
let combinedChecks = checks[0]
|
|
22
|
+
for (let i = 1; i < checks.length; i++) {
|
|
23
|
+
combinedChecks += ' && ' + checks[i]
|
|
24
|
+
}
|
|
25
|
+
cases.push('(' + combinedChecks + ')')
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (cases.length === 0) {
|
|
30
|
+
return isRequired ? `${accessor} ?? ${defaultValue}` : accessor
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Try each schema in order
|
|
34
|
+
let combinedCheck = cases[0]
|
|
35
|
+
for (let i = 1; i < cases.length; i++) {
|
|
36
|
+
combinedCheck += ' || ' + cases[i]
|
|
37
|
+
}
|
|
38
|
+
const finalDefault = isRequired ? defaultValue : 'undefined'
|
|
39
|
+
return `(${combinedCheck}) ? ${accessor} : ${finalDefault}`
|
|
40
|
+
}
|