@amritk/generate-parsers 0.1.0 → 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/README.md +3 -1
- package/dist/generators/build-schema.d.ts +8 -1
- package/dist/generators/generate-files.d.ts +20 -2
- package/dist/generators/generate-parser-function.d.ts +6 -0
- package/dist/generators/generate-strict-assertion.d.ts +18 -0
- package/dist/helpers/collect-helpers.d.ts +16 -2
- package/dist/index.js +261 -50
- package/package.json +6 -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,280 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { dirname, resolve as resolvePath } from 'node:path'
|
|
4
|
+
import { buildDynamicRefMap } from '@amritk/helpers/build-dynamic-ref-map'
|
|
5
|
+
import { extractRefs } from '@amritk/helpers/extract-refs'
|
|
6
|
+
import { refToFilename } from '@amritk/helpers/ref-to-filename'
|
|
7
|
+
import { refToName } from '@amritk/helpers/ref-to-name'
|
|
8
|
+
import { resolveDynamicRefs } from '@amritk/helpers/resolve-dynamic-refs'
|
|
9
|
+
import { resolveRef } from '@amritk/helpers/resolve-ref'
|
|
10
|
+
import { upgradeDraft07Schema } from '@amritk/helpers/upgrade-draft07-schema'
|
|
11
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
|
|
12
|
+
import { applySchemaExtensions } from '#helpers/apply-schema-extensions'
|
|
13
|
+
import type { HelpersMode, RuntimeHelperName } from '#helpers/collect-helpers'
|
|
14
|
+
import type { SchemaExtensions } from '#types/schema-extensions'
|
|
15
|
+
|
|
16
|
+
import { generateFile } from './generate-files'
|
|
17
|
+
|
|
18
|
+
/** Locate the @amritk/helpers package on disk so we can copy its runtime
|
|
19
|
+
* helper source files into the generated output when in embedded mode. */
|
|
20
|
+
const readHelperSource = async (helper: RuntimeHelperName): Promise<string> => {
|
|
21
|
+
const require = createRequire(import.meta.url)
|
|
22
|
+
const helpersPkgPath = require.resolve('@amritk/helpers/package.json')
|
|
23
|
+
const helpersRoot = dirname(helpersPkgPath)
|
|
24
|
+
return readFile(resolvePath(helpersRoot, 'src', `${helper}.ts`), 'utf-8')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Represents a generated file with its filename and content.
|
|
29
|
+
*/
|
|
30
|
+
export type GeneratedFile = {
|
|
31
|
+
filename: string
|
|
32
|
+
content: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Extracts all definitions from $defs that have a $dynamicAnchor.
|
|
37
|
+
* These definitions need to be generated even if not directly referenced by $ref.
|
|
38
|
+
*/
|
|
39
|
+
const extractDynamicAnchorDefs = (schema: JSONSchema): string[] => {
|
|
40
|
+
const refs: string[] = []
|
|
41
|
+
|
|
42
|
+
if (typeof schema !== 'object' || schema === null) {
|
|
43
|
+
return refs
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Check if schema has $defs
|
|
47
|
+
if (!('$defs' in schema) || typeof schema['$defs'] !== 'object' || schema['$defs'] === null) {
|
|
48
|
+
return refs
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const defs = schema['$defs']
|
|
52
|
+
|
|
53
|
+
// Find all definitions with $dynamicAnchor
|
|
54
|
+
for (const [key, value] of Object.entries(defs)) {
|
|
55
|
+
if (typeof value === 'object' && value !== null) {
|
|
56
|
+
const defSchema = value as Record<string, unknown>
|
|
57
|
+
if ('$dynamicAnchor' in defSchema) {
|
|
58
|
+
// Convert to a $ref-style path
|
|
59
|
+
refs.push(`#/$defs/${key}`)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return refs
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Builds all TypeScript files from a JSON Schema by traversing
|
|
69
|
+
* all $ref references recursively.
|
|
70
|
+
*
|
|
71
|
+
* Starting from the root schema, this function:
|
|
72
|
+
* 1. Generates a TypeScript file for the root type
|
|
73
|
+
* 2. Extracts all $ref references from the schema
|
|
74
|
+
* 3. Extracts all definitions with $dynamicAnchor (JSON Schema 2020-12 feature)
|
|
75
|
+
* 4. Resolves each ref and generates a TypeScript file for it
|
|
76
|
+
* 5. Recursively processes nested refs until all are handled
|
|
77
|
+
*
|
|
78
|
+
* @param rootSchema - The root JSON Schema to build from
|
|
79
|
+
* @param rootTypeName - The name for the root type (e.g., "Document")
|
|
80
|
+
* @param extensions - Optional map of custom extension properties to add to specific definitions.
|
|
81
|
+
* Keys are definition names (matching $defs keys), values are records of extension property
|
|
82
|
+
* names to their JSON Schema definitions. Extensions are merged as optional properties before
|
|
83
|
+
* type and parser generation.
|
|
84
|
+
* @param typesOnly - When true, only generate TypeScript type definitions without parser functions.
|
|
85
|
+
* @param logWarnings - When true, the generated parsers emit a console.warn for every input key
|
|
86
|
+
* that is not declared in the schema's properties.
|
|
87
|
+
* @param strict - When true, the generated parsers throw on type/shape mismatches
|
|
88
|
+
* (wrong type, missing required property, enum/pattern/min/max violations) instead
|
|
89
|
+
* of coercing invalid input to default values.
|
|
90
|
+
* @param helpersMode - `'package'` (default) emits `import ... from '@amritk/helpers/...'`.
|
|
91
|
+
* `'embedded'` emits `import ... from './_helpers/...'` and appends the helper sources
|
|
92
|
+
* as additional `GeneratedFile` entries so the output directory is self-contained.
|
|
93
|
+
* @returns An array of generated TypeScript files
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* ```typescript
|
|
97
|
+
* const schema = {
|
|
98
|
+
* type: "object",
|
|
99
|
+
* properties: {
|
|
100
|
+
* info: { $ref: "#/$defs/info" }
|
|
101
|
+
* },
|
|
102
|
+
* $defs: {
|
|
103
|
+
* info: {
|
|
104
|
+
* type: "object",
|
|
105
|
+
* properties: {
|
|
106
|
+
* title: { type: "string" }
|
|
107
|
+
* }
|
|
108
|
+
* }
|
|
109
|
+
* }
|
|
110
|
+
* };
|
|
111
|
+
*
|
|
112
|
+
* const files = buildSchema(schema, "Document");
|
|
113
|
+
*
|
|
114
|
+
* // Types-only mode — no parser functions or runtime helpers included:
|
|
115
|
+
* const typesFiles = buildSchema(schema, "Document", undefined, true);
|
|
116
|
+
*
|
|
117
|
+
* // With extensions:
|
|
118
|
+
* const filesWithExtensions = buildSchema(schema, "Document", {
|
|
119
|
+
* info: {
|
|
120
|
+
* 'x-internal': { type: 'boolean' },
|
|
121
|
+
* },
|
|
122
|
+
* });
|
|
123
|
+
* ```
|
|
124
|
+
*/
|
|
125
|
+
export const buildSchema = async (
|
|
126
|
+
rootSchema: JSONSchema,
|
|
127
|
+
rootTypeName: string,
|
|
128
|
+
extensions?: SchemaExtensions,
|
|
129
|
+
typesOnly?: boolean,
|
|
130
|
+
logWarnings?: boolean,
|
|
131
|
+
strict?: boolean,
|
|
132
|
+
helpersMode: HelpersMode = 'package',
|
|
133
|
+
): Promise<GeneratedFile[]> => {
|
|
134
|
+
// Upgrade draft-07 schemas to 2020-12 conventions before processing.
|
|
135
|
+
// This renames `definitions` → `$defs` recursively so the rest of the
|
|
136
|
+
// pipeline can resolve both short-name and URI-keyed refs uniformly.
|
|
137
|
+
rootSchema = upgradeDraft07Schema(rootSchema as Record<string, unknown>) as JSONSchema
|
|
138
|
+
|
|
139
|
+
const files: GeneratedFile[] = []
|
|
140
|
+
const processedRefs = new Set<string>()
|
|
141
|
+
const processedFilenames = new Set<string>()
|
|
142
|
+
const refsToProcess: string[] = []
|
|
143
|
+
const usedHelpers = new Set<RuntimeHelperName>()
|
|
144
|
+
|
|
145
|
+
// Build a map of $dynamicRef anchors to their $ref paths so we can
|
|
146
|
+
// convert $dynamicRef: "#meta" to $ref: "#/$defs/schema" before generating
|
|
147
|
+
const dynamicRefMap = buildDynamicRefMap(rootSchema)
|
|
148
|
+
|
|
149
|
+
// Generate file for the root schema, applying any matching extensions
|
|
150
|
+
const processedRootSchema = resolveDynamicRefs(rootSchema, dynamicRefMap)
|
|
151
|
+
const extendedRootSchema = extensions
|
|
152
|
+
? applySchemaExtensions(processedRootSchema, rootTypeName.toLowerCase(), extensions)
|
|
153
|
+
: processedRootSchema
|
|
154
|
+
const rootResult = generateFile(extendedRootSchema, rootTypeName, {
|
|
155
|
+
typesOnly: typesOnly ?? false,
|
|
156
|
+
rootSchema: rootSchema as Record<string, unknown>,
|
|
157
|
+
helpersMode,
|
|
158
|
+
...(logWarnings !== undefined ? { logWarnings } : {}),
|
|
159
|
+
...(strict !== undefined ? { strict } : {}),
|
|
160
|
+
})
|
|
161
|
+
const rootFilename = rootTypeName.toLowerCase()
|
|
162
|
+
|
|
163
|
+
processedFilenames.add(rootFilename)
|
|
164
|
+
files.push({
|
|
165
|
+
filename: `${rootFilename}.ts`,
|
|
166
|
+
content: rootResult.content,
|
|
167
|
+
})
|
|
168
|
+
for (const helper of rootResult.usedHelpers) usedHelpers.add(helper)
|
|
169
|
+
|
|
170
|
+
// Extract all refs from the root schema
|
|
171
|
+
const rootRefs = extractRefs(rootSchema)
|
|
172
|
+
refsToProcess.push(...rootRefs)
|
|
173
|
+
|
|
174
|
+
// Extract all definitions with $dynamicAnchor
|
|
175
|
+
const dynamicAnchorRefs = extractDynamicAnchorDefs(rootSchema)
|
|
176
|
+
refsToProcess.push(...dynamicAnchorRefs)
|
|
177
|
+
|
|
178
|
+
// Process refs until none remain
|
|
179
|
+
while (refsToProcess.length > 0) {
|
|
180
|
+
const ref = refsToProcess.shift()
|
|
181
|
+
|
|
182
|
+
// Skip if already processed
|
|
183
|
+
if (!ref || processedRefs.has(ref)) {
|
|
184
|
+
continue
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
processedRefs.add(ref)
|
|
188
|
+
|
|
189
|
+
// Resolve the ref to get the actual schema
|
|
190
|
+
const resolvedSchema = resolveRef(ref, rootSchema as Record<string, unknown>)
|
|
191
|
+
|
|
192
|
+
if (!resolvedSchema) {
|
|
193
|
+
console.warn(`Warning: Could not resolve ref: ${ref}`)
|
|
194
|
+
continue
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Generate file for this ref, resolving any $dynamicRef to $ref first
|
|
198
|
+
// and applying any matching extensions for this definition
|
|
199
|
+
const typeName = refToName(ref)
|
|
200
|
+
const filename = refToFilename(ref)
|
|
201
|
+
const processedSchema = resolveDynamicRefs(resolvedSchema as JSONSchema, dynamicRefMap)
|
|
202
|
+
const extendedSchema = extensions ? applySchemaExtensions(processedSchema, filename, extensions) : processedSchema
|
|
203
|
+
const fileResult = generateFile(extendedSchema, typeName, {
|
|
204
|
+
typesOnly: typesOnly ?? false,
|
|
205
|
+
selfRef: ref,
|
|
206
|
+
rootSchema: rootSchema as Record<string, unknown>,
|
|
207
|
+
helpersMode,
|
|
208
|
+
...(logWarnings !== undefined ? { logWarnings } : {}),
|
|
209
|
+
...(strict !== undefined ? { strict } : {}),
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
if (!processedFilenames.has(filename)) {
|
|
213
|
+
processedFilenames.add(filename)
|
|
214
|
+
files.push({
|
|
215
|
+
filename: `${filename}.ts`,
|
|
216
|
+
content: fileResult.content,
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
for (const helper of fileResult.usedHelpers) usedHelpers.add(helper)
|
|
220
|
+
|
|
221
|
+
// Extract refs from this schema and add to queue
|
|
222
|
+
const nestedRefs = extractRefs(resolvedSchema as JSONSchema)
|
|
223
|
+
for (const nestedRef of nestedRefs) {
|
|
224
|
+
if (!processedRefs.has(nestedRef)) {
|
|
225
|
+
refsToProcess.push(nestedRef)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// In embedded mode, ship the runtime helper source files alongside the parsers so
|
|
231
|
+
// the output directory is self-contained (no `@amritk/helpers` install required).
|
|
232
|
+
// typesOnly skips parser generation entirely, so no runtime helpers are needed.
|
|
233
|
+
if (helpersMode === 'embedded' && !typesOnly) {
|
|
234
|
+
for (const helper of usedHelpers) {
|
|
235
|
+
const source = await readHelperSource(helper)
|
|
236
|
+
files.push({ filename: `_helpers/${helper}.ts`, content: source })
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Generate index.ts with named re-exports extracted from each generated file's content.
|
|
241
|
+
// Regex matches `export type Foo` and `export const foo` declarations.
|
|
242
|
+
const TYPE_EXPORT_RE = /^export type (\w+)/gm
|
|
243
|
+
const CONST_EXPORT_RE = /^export const (\w+)/gm
|
|
244
|
+
|
|
245
|
+
// _helpers/ is an internal directory; never re-export from it.
|
|
246
|
+
const sortedFiles = [...files]
|
|
247
|
+
.filter((f) => !f.filename.startsWith('_helpers/'))
|
|
248
|
+
.sort((a, b) => a.filename.localeCompare(b.filename))
|
|
249
|
+
|
|
250
|
+
let indexContent = ''
|
|
251
|
+
for (const file of sortedFiles) {
|
|
252
|
+
const moduleName = file.filename.replace(/\.ts$/, '')
|
|
253
|
+
const typeNames: string[] = []
|
|
254
|
+
const constNames: string[] = []
|
|
255
|
+
|
|
256
|
+
for (const match of file.content.matchAll(TYPE_EXPORT_RE)) {
|
|
257
|
+
typeNames.push(match[1] as string)
|
|
258
|
+
}
|
|
259
|
+
for (const match of file.content.matchAll(CONST_EXPORT_RE)) {
|
|
260
|
+
constNames.push(match[1] as string)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (typeNames.length === 0 && constNames.length === 0) continue
|
|
264
|
+
|
|
265
|
+
if (typesOnly) {
|
|
266
|
+
indexContent += `export type { ${typeNames.join(', ')} } from './${moduleName}';\n`
|
|
267
|
+
} else {
|
|
268
|
+
const typeExports = typeNames.map((n) => `type ${n}`)
|
|
269
|
+
const allExports = [...typeExports, ...constNames]
|
|
270
|
+
indexContent += `export { ${allExports.join(', ')} } from './${moduleName}';\n`
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
files.push({
|
|
275
|
+
filename: 'index.ts',
|
|
276
|
+
content: indexContent,
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
return files
|
|
280
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { generateDefaultFromPattern } from './generate-default-from-pattern'
|
|
4
|
+
|
|
5
|
+
describe('generate-default-from-pattern', () => {
|
|
6
|
+
it('returns email default for pattern containing @', () => {
|
|
7
|
+
const result = generateDefaultFromPattern('^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$')
|
|
8
|
+
expect(result).toBe('"user@example.com"')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('returns email default for pattern with character class and dot', () => {
|
|
12
|
+
const result = generateDefaultFromPattern('[a-zA-Z0-9].*\\.')
|
|
13
|
+
expect(result).toBe('"user@example.com"')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('returns UUID default for hex pattern with {8}', () => {
|
|
17
|
+
const result = generateDefaultFromPattern('^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')
|
|
18
|
+
expect(result).toBe('"00000000-0000-0000-0000-000000000000"')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('returns UUID default for pattern with dash separator', () => {
|
|
22
|
+
const result = generateDefaultFromPattern('[0-9a-f]{8}\\-[0-9a-f]{4}')
|
|
23
|
+
expect(result).toBe('"00000000-0000-0000-0000-000000000000"')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('returns null for hex pattern without dash or matching structure', () => {
|
|
27
|
+
const result = generateDefaultFromPattern('^[0-9a-fA-F]{8}')
|
|
28
|
+
expect(result).toBeNull()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('returns URL default for https? pattern', () => {
|
|
32
|
+
const result = generateDefaultFromPattern('^https?://')
|
|
33
|
+
expect(result).toBe('"https://example.com"')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('returns URL default for http pattern', () => {
|
|
37
|
+
const result = generateDefaultFromPattern('^http://')
|
|
38
|
+
expect(result).toBe('"https://example.com"')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('returns semver default for version-like pattern with literal digits', () => {
|
|
42
|
+
// The regex looks for actual digit chars and escaped dots in the pattern string
|
|
43
|
+
const result = generateDefaultFromPattern('3\\.1\\.\\d+')
|
|
44
|
+
expect(result).toBe('"1.0.0"')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('returns null for version pattern without literal digits', () => {
|
|
48
|
+
const result = generateDefaultFromPattern('^\\d+\\.\\d+\\.\\d+$')
|
|
49
|
+
expect(result).toBeNull()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('returns ISO date default for date-like pattern', () => {
|
|
53
|
+
const result = generateDefaultFromPattern('^\\d{4}-\\d{2}-\\d{2}$')
|
|
54
|
+
expect(result).toBe('"2000-01-01"')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('returns phone number default for phone-like pattern with {3} and {4}', () => {
|
|
58
|
+
const result = generateDefaultFromPattern('^\\+?\\d{3}\\d{4}$')
|
|
59
|
+
expect(result).toBe('"+1234567890"')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('returns phone number default for phone-like pattern with {3} and {7}', () => {
|
|
63
|
+
const result = generateDefaultFromPattern('^\\d{3}\\d{7}$')
|
|
64
|
+
expect(result).toBe('"+1234567890"')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('returns null for unrecognized pattern', () => {
|
|
68
|
+
const result = generateDefaultFromPattern('^[a-z]+$')
|
|
69
|
+
expect(result).toBeNull()
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('returns null for empty pattern', () => {
|
|
73
|
+
const result = generateDefaultFromPattern('')
|
|
74
|
+
expect(result).toBeNull()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('returns null for simple wildcard pattern', () => {
|
|
78
|
+
const result = generateDefaultFromPattern('.*')
|
|
79
|
+
expect(result).toBeNull()
|
|
80
|
+
})
|
|
81
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates a best-guess default value from a regex pattern.
|
|
3
|
+
* Recognizes common patterns and returns appropriate defaults.
|
|
4
|
+
*/
|
|
5
|
+
export const generateDefaultFromPattern = (pattern: string): string | null => {
|
|
6
|
+
// Email pattern
|
|
7
|
+
if (pattern.includes('@') || (pattern.includes('[a-zA-Z0-9]') && pattern.includes('\\.'))) {
|
|
8
|
+
return '"user@example.com"'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// UUID pattern
|
|
12
|
+
if ((pattern.includes('[0-9a-f]') && pattern.includes('{8}')) || pattern.includes('\\-')) {
|
|
13
|
+
if (pattern.match(/[0-9a-f].*\{8\}/i) || pattern.match(/[0-9a-fA-F]{8}/)) {
|
|
14
|
+
return '"00000000-0000-0000-0000-000000000000"'
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// URL pattern
|
|
19
|
+
if (pattern.includes('https?') || pattern.includes('http')) {
|
|
20
|
+
return '"https://example.com"'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Semver pattern (3.1.x, x.y.z, etc)
|
|
24
|
+
if (pattern.match(/\d.*\\\.\.*\d/)) {
|
|
25
|
+
return '"1.0.0"'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ISO date pattern
|
|
29
|
+
if (pattern.includes('\\d{4}') && pattern.includes('\\d{2}')) {
|
|
30
|
+
return '"2000-01-01"'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Phone number patterns
|
|
34
|
+
if (pattern.includes('\\d{3}') && (pattern.includes('\\d{4}') || pattern.includes('\\d{7}'))) {
|
|
35
|
+
return '"+1234567890"'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { generateDiscriminatedUnionValidation } from './generate-discriminated-union-validation'
|
|
4
|
+
|
|
5
|
+
describe('generate-discriminated-union-validation', () => {
|
|
6
|
+
it('generates ternary chain for schemas with discriminator values', () => {
|
|
7
|
+
const schemas = [
|
|
8
|
+
{
|
|
9
|
+
type: 'object' as const,
|
|
10
|
+
properties: { kind: { const: 'a' }, name: { type: 'string' as const } },
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
type: 'object' as const,
|
|
14
|
+
properties: { kind: { const: 'b' }, count: { type: 'number' as const } },
|
|
15
|
+
},
|
|
16
|
+
]
|
|
17
|
+
const result = generateDiscriminatedUnionValidation('value', schemas, 'kind', '{}', true)
|
|
18
|
+
expect(result).toContain('value?.kind === "a"')
|
|
19
|
+
expect(result).toContain('value?.kind === "b"')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('returns fallback expression when no schemas have discriminator values', () => {
|
|
23
|
+
const schemas = [{ type: 'object' as const, properties: { name: { type: 'string' as const } } }]
|
|
24
|
+
const result = generateDiscriminatedUnionValidation('value', schemas, 'kind', '{}', true)
|
|
25
|
+
expect(result).toBe('value ?? {}')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('returns accessor when not required and no discriminator values', () => {
|
|
29
|
+
const schemas = [{ type: 'object' as const, properties: { name: { type: 'string' as const } } }]
|
|
30
|
+
const result = generateDiscriminatedUnionValidation('value', schemas, 'kind', '{}', false)
|
|
31
|
+
expect(result).toBe('value')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('includes schema checks in ternary when type checks exist', () => {
|
|
35
|
+
const schemas = [
|
|
36
|
+
{
|
|
37
|
+
type: 'object' as const,
|
|
38
|
+
properties: { kind: { const: 'user' } },
|
|
39
|
+
},
|
|
40
|
+
]
|
|
41
|
+
const result = generateDiscriminatedUnionValidation('input', schemas, 'kind', '{}', true)
|
|
42
|
+
expect(result).toContain('input?.kind === "user"')
|
|
43
|
+
// Should include object type checks
|
|
44
|
+
expect(result).toContain('typeof input === "object"')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('handles schemas with single enum values as discriminators', () => {
|
|
48
|
+
const schemas = [
|
|
49
|
+
{
|
|
50
|
+
type: 'object' as const,
|
|
51
|
+
properties: { type: { enum: ['cat'] } },
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
type: 'object' as const,
|
|
55
|
+
properties: { type: { enum: ['dog'] } },
|
|
56
|
+
},
|
|
57
|
+
]
|
|
58
|
+
const result = generateDiscriminatedUnionValidation('value', schemas, 'type', '{}', true)
|
|
59
|
+
expect(result).toContain('value?.type === "cat"')
|
|
60
|
+
expect(result).toContain('value?.type === "dog"')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('handles empty schemas array', () => {
|
|
64
|
+
const result = generateDiscriminatedUnionValidation('value', [], 'type', '{}', true)
|
|
65
|
+
expect(result).toBe('value ?? {}')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('skips schemas without a discriminator value', () => {
|
|
69
|
+
const schemas = [
|
|
70
|
+
{
|
|
71
|
+
type: 'object' as const,
|
|
72
|
+
properties: { kind: { const: 'a' } },
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
type: 'object' as const,
|
|
76
|
+
properties: { name: { type: 'string' as const } },
|
|
77
|
+
},
|
|
78
|
+
]
|
|
79
|
+
const result = generateDiscriminatedUnionValidation('value', schemas, 'kind', '{}', true)
|
|
80
|
+
expect(result).toContain('value?.kind === "a"')
|
|
81
|
+
// The second schema has no discriminator, so it does not appear in the ternary chain
|
|
82
|
+
expect(result).not.toContain('value?.kind === undefined')
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
|
|
2
|
+
import { getDefaultValue } from '#helpers/get-default-value'
|
|
3
|
+
import { getDiscriminatorValue } from '#helpers/get-discriminator-value'
|
|
4
|
+
|
|
5
|
+
import { generateSchemaChecks } from './generate-schema-checks'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Generates a discriminated union validation expression.
|
|
9
|
+
* Uses a discriminator property to efficiently choose the correct schema.
|
|
10
|
+
*/
|
|
11
|
+
export const generateDiscriminatedUnionValidation = (
|
|
12
|
+
accessor: string,
|
|
13
|
+
schemas: readonly JSONSchema[],
|
|
14
|
+
discriminatorKey: string,
|
|
15
|
+
defaultValue: string,
|
|
16
|
+
isRequired: boolean,
|
|
17
|
+
): string => {
|
|
18
|
+
const cases: string[] = []
|
|
19
|
+
|
|
20
|
+
for (const schema of schemas) {
|
|
21
|
+
const discriminatorValue = getDiscriminatorValue(schema, discriminatorKey)
|
|
22
|
+
if (discriminatorValue !== null) {
|
|
23
|
+
const schemaDefault = getDefaultValue(schema)
|
|
24
|
+
const checks = generateSchemaChecks(accessor, schema)
|
|
25
|
+
|
|
26
|
+
if (checks.length > 0) {
|
|
27
|
+
// checks[0] is safe: we guard with checks.length > 0 above
|
|
28
|
+
let combinedChecks = checks[0] as string
|
|
29
|
+
for (let i = 1; i < checks.length; i++) {
|
|
30
|
+
combinedChecks += ' && ' + checks[i]
|
|
31
|
+
}
|
|
32
|
+
cases.push(
|
|
33
|
+
`${accessor}?.${discriminatorKey} === ${JSON.stringify(discriminatorValue)} && ${combinedChecks} ? ${accessor} : ${schemaDefault}`,
|
|
34
|
+
)
|
|
35
|
+
} else {
|
|
36
|
+
cases.push(
|
|
37
|
+
`${accessor}?.${discriminatorKey} === ${JSON.stringify(discriminatorValue)} ? ${accessor} : ${schemaDefault}`,
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (cases.length === 0) {
|
|
44
|
+
return isRequired ? `${accessor} ?? ${defaultValue}` : accessor
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Chain the cases together with ternary operators using a for loop instead of .reduce()
|
|
48
|
+
// cases[0] is safe: we guard with cases.length === 0 above
|
|
49
|
+
let result = cases[0] as string
|
|
50
|
+
for (let i = 1; i < cases.length; i++) {
|
|
51
|
+
result = cases[i] + ' : ' + result
|
|
52
|
+
}
|
|
53
|
+
return result
|
|
54
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { generateEnumCheck } from './generate-enum-check'
|
|
4
|
+
|
|
5
|
+
describe('generate-enum-check', () => {
|
|
6
|
+
it('generates an includes check for string enum values', () => {
|
|
7
|
+
const result = generateEnumCheck('value', ['a', 'b', 'c'])
|
|
8
|
+
expect(result).toBe('["a","b","c"].includes(value as never)')
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
it('generates an includes check for numeric enum values', () => {
|
|
12
|
+
const result = generateEnumCheck('value', [1, 2, 3])
|
|
13
|
+
expect(result).toBe('[1,2,3].includes(value as never)')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('generates an includes check for mixed type enum values', () => {
|
|
17
|
+
const result = generateEnumCheck('value', ['a', 1, true, null])
|
|
18
|
+
expect(result).toBe('["a",1,true,null].includes(value as never)')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('handles a single enum value', () => {
|
|
22
|
+
const result = generateEnumCheck('value', ['only'])
|
|
23
|
+
expect(result).toBe('["only"].includes(value as never)')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('handles an empty enum array', () => {
|
|
27
|
+
const result = generateEnumCheck('value', [])
|
|
28
|
+
expect(result).toBe('[].includes(value as never)')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('uses the provided accessor in the expression', () => {
|
|
32
|
+
const result = generateEnumCheck('input?.type', ['a', 'b'])
|
|
33
|
+
expect(result).toBe('["a","b"].includes(input?.type as never)')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('handles enum values with special characters', () => {
|
|
37
|
+
const result = generateEnumCheck('value', ['hello world', 'foo"bar'])
|
|
38
|
+
expect(result).toBe('["hello world","foo\\"bar"].includes(value as never)')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('handles boolean enum values', () => {
|
|
42
|
+
const result = generateEnumCheck('value', [true, false])
|
|
43
|
+
expect(result).toBe('[true,false].includes(value as never)')
|
|
44
|
+
})
|
|
45
|
+
})
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generates an enum validation check expression.
|
|
3
|
+
* Returns a string that checks if the value is in the allowed enum values.
|
|
4
|
+
*/
|
|
5
|
+
export const generateEnumCheck = (accessor: string, enumValues: readonly unknown[]): string => {
|
|
6
|
+
const serializedEnum = JSON.stringify(enumValues)
|
|
7
|
+
return `${serializedEnum}.includes(${accessor} as never)`
|
|
8
|
+
}
|