@amritk/generate-validators 0.1.0 → 0.2.2
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/index.js +3 -3
- package/package.json +6 -2
- package/src/generators/build-schema.test.ts +163 -0
- package/src/generators/build-schema.ts +140 -0
- package/src/generators/collect-validator-imports.ts +128 -0
- package/src/generators/generate-files.ts +72 -0
- package/src/generators/generate-validator-function.test.ts +167 -0
- package/src/generators/generate-validator-function.ts +399 -0
- package/src/index.ts +2 -0
package/dist/index.js
CHANGED
|
@@ -773,6 +773,9 @@ var generateTypeDefinition = (schema, typeName) => {
|
|
|
773
773
|
};
|
|
774
774
|
|
|
775
775
|
// ../helpers/dist/schema-guards.js
|
|
776
|
+
var hasRef = (value) => {
|
|
777
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && "$ref" in value && typeof value.$ref === "string";
|
|
778
|
+
};
|
|
776
779
|
var isSchemaObject3 = (schema) => {
|
|
777
780
|
return typeof schema === "object" && schema !== null && typeof schema !== "boolean";
|
|
778
781
|
};
|
|
@@ -830,9 +833,6 @@ var hasExclusiveMaximum = (schema) => {
|
|
|
830
833
|
var hasMultipleOf = (schema) => {
|
|
831
834
|
return isSchemaObject3(schema) && "multipleOf" in schema && typeof schema.multipleOf === "number";
|
|
832
835
|
};
|
|
833
|
-
var hasRef = (schema) => {
|
|
834
|
-
return isSchemaObject3(schema) && "$ref" in schema && typeof schema.$ref === "string";
|
|
835
|
-
};
|
|
836
836
|
|
|
837
837
|
// src/generators/collect-validator-imports.ts
|
|
838
838
|
var buildImport = (ref) => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amritk/generate-validators",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Generate TypeScript validation functions from JSON Schemas.",
|
|
5
5
|
"module": "./dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -24,8 +24,12 @@
|
|
|
24
24
|
"url": "https://github.com/amritk/mjst/issues"
|
|
25
25
|
},
|
|
26
26
|
"files": [
|
|
27
|
-
"dist"
|
|
27
|
+
"dist",
|
|
28
|
+
"src"
|
|
28
29
|
],
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
29
33
|
"scripts": {
|
|
30
34
|
"build": "bun run build:code && bun run build:types",
|
|
31
35
|
"build:code": "bun build ./src/index.ts --outdir=dist --target=node",
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { validate } from '@scalar/openapi-parser'
|
|
2
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { buildValidatorSchema } from './build-schema'
|
|
6
|
+
|
|
7
|
+
describe('build-schema', () => {
|
|
8
|
+
it('generates a validator file for the root schema', async () => {
|
|
9
|
+
const schema: JSONSchema = {
|
|
10
|
+
type: 'object',
|
|
11
|
+
properties: { title: { type: 'string' } },
|
|
12
|
+
required: ['title'],
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const files = await buildValidatorSchema(schema, 'Document')
|
|
16
|
+
const filenames = files.map((f) => f.filename)
|
|
17
|
+
|
|
18
|
+
expect(filenames).toContain('document.ts')
|
|
19
|
+
expect(filenames).toContain('validation-result.ts')
|
|
20
|
+
expect(filenames).toContain('index.ts')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('generates a file per $ref definition', async () => {
|
|
24
|
+
const schema: JSONSchema = {
|
|
25
|
+
type: 'object',
|
|
26
|
+
properties: {
|
|
27
|
+
info: { $ref: '#/$defs/info' },
|
|
28
|
+
},
|
|
29
|
+
$defs: {
|
|
30
|
+
info: {
|
|
31
|
+
type: 'object',
|
|
32
|
+
properties: { title: { type: 'string' } },
|
|
33
|
+
required: ['title'],
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const files = await buildValidatorSchema(schema, 'Document')
|
|
39
|
+
const filenames = files.map((f) => f.filename)
|
|
40
|
+
|
|
41
|
+
expect(filenames).toContain('document.ts')
|
|
42
|
+
expect(filenames).toContain('info.ts')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('generated document.ts exports a validateDocument function', async () => {
|
|
46
|
+
const schema: JSONSchema = {
|
|
47
|
+
type: 'object',
|
|
48
|
+
properties: { title: { type: 'string' } },
|
|
49
|
+
required: ['title'],
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const files = await buildValidatorSchema(schema, 'Document')
|
|
53
|
+
const documentFile = files.find((f) => f.filename === 'document.ts')
|
|
54
|
+
|
|
55
|
+
expect(documentFile?.content).toContain('export const validateDocument')
|
|
56
|
+
expect(documentFile?.content).toContain('ValidationResult')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('generated file imports the ref validator for $ref properties', async () => {
|
|
60
|
+
const schema: JSONSchema = {
|
|
61
|
+
type: 'object',
|
|
62
|
+
properties: {
|
|
63
|
+
info: { $ref: '#/$defs/info' },
|
|
64
|
+
},
|
|
65
|
+
$defs: {
|
|
66
|
+
info: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
properties: { title: { type: 'string' } },
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const files = await buildValidatorSchema(schema, 'Document')
|
|
74
|
+
const documentFile = files.find((f) => f.filename === 'document.ts')
|
|
75
|
+
|
|
76
|
+
expect(documentFile?.content).toContain("from './info'")
|
|
77
|
+
expect(documentFile?.content).toContain('validateInfoObject')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('generates a valid index.ts with re-exports', async () => {
|
|
81
|
+
const schema: JSONSchema = {
|
|
82
|
+
type: 'object',
|
|
83
|
+
properties: { title: { type: 'string' } },
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const files = await buildValidatorSchema(schema, 'Document')
|
|
87
|
+
const indexFile = files.find((f) => f.filename === 'index.ts')
|
|
88
|
+
|
|
89
|
+
expect(indexFile?.content).toContain("from './document'")
|
|
90
|
+
expect(indexFile?.content).toContain('validateDocument')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('does not generate a file named validation-result for a schema ref', async () => {
|
|
94
|
+
const schema: JSONSchema = {
|
|
95
|
+
type: 'object',
|
|
96
|
+
properties: {
|
|
97
|
+
result: { $ref: '#/$defs/validation-result' },
|
|
98
|
+
},
|
|
99
|
+
$defs: {
|
|
100
|
+
'validation-result': { type: 'object' },
|
|
101
|
+
},
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const files = await buildValidatorSchema(schema, 'Document')
|
|
105
|
+
// Should still have exactly one validation-result.ts (the runtime contract)
|
|
106
|
+
const vrFiles = files.filter((f) => f.filename === 'validation-result.ts')
|
|
107
|
+
expect(vrFiles).toHaveLength(1)
|
|
108
|
+
expect(vrFiles[0]?.content).toContain('export type ValidationResult')
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('produces generated validators that agree with @scalar/openapi-parser on a valid document', async () => {
|
|
112
|
+
// Build validators for a minimal OpenAPI-like schema
|
|
113
|
+
const schema: JSONSchema = {
|
|
114
|
+
type: 'object',
|
|
115
|
+
properties: {
|
|
116
|
+
openapi: { type: 'string' },
|
|
117
|
+
info: { $ref: '#/$defs/info' },
|
|
118
|
+
},
|
|
119
|
+
required: ['openapi', 'info'],
|
|
120
|
+
$defs: {
|
|
121
|
+
info: {
|
|
122
|
+
type: 'object',
|
|
123
|
+
properties: {
|
|
124
|
+
title: { type: 'string' },
|
|
125
|
+
version: { type: 'string' },
|
|
126
|
+
},
|
|
127
|
+
required: ['title', 'version'],
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const files = await buildValidatorSchema(schema, 'Document')
|
|
133
|
+
|
|
134
|
+
// Sanity-check generated code shape
|
|
135
|
+
const documentFile = files.find((f) => f.filename === 'document.ts')
|
|
136
|
+
const infoFile = files.find((f) => f.filename === 'info.ts')
|
|
137
|
+
|
|
138
|
+
expect(documentFile?.content).toContain('validateDocument')
|
|
139
|
+
expect(infoFile?.content).toContain('validateInfoObject')
|
|
140
|
+
|
|
141
|
+
// Cross-check: @scalar/openapi-parser says a complete document is valid
|
|
142
|
+
const validDoc = { openapi: '3.1.0', info: { title: 'API', version: '1.0' }, paths: {} }
|
|
143
|
+
const refResult = await validate(validDoc)
|
|
144
|
+
expect(refResult.valid).toBe(true)
|
|
145
|
+
|
|
146
|
+
// Cross-check: @scalar/openapi-parser says a document missing info.title is invalid
|
|
147
|
+
const invalidDoc = { openapi: '3.1.0', info: { version: '1.0' }, paths: {} }
|
|
148
|
+
const refInvalid = await validate(invalidDoc)
|
|
149
|
+
expect(refInvalid.valid).toBe(false)
|
|
150
|
+
expect(refInvalid.errors.some((e) => e.message.includes('title'))).toBe(true)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('emits a validation-result.ts with the runtime ValidationResult/ValidationError types', async () => {
|
|
154
|
+
const schema: JSONSchema = { type: 'object' }
|
|
155
|
+
const files = await buildValidatorSchema(schema, 'Doc')
|
|
156
|
+
const vrFile = files.find((f) => f.filename === 'validation-result.ts')
|
|
157
|
+
|
|
158
|
+
expect(vrFile?.content).toContain('export type ValidationError')
|
|
159
|
+
expect(vrFile?.content).toContain('export type ValidationResult')
|
|
160
|
+
expect(vrFile?.content).toContain('message: string')
|
|
161
|
+
expect(vrFile?.content).toContain('path: string')
|
|
162
|
+
})
|
|
163
|
+
})
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { buildDynamicRefMap } from '@amritk/helpers/build-dynamic-ref-map'
|
|
2
|
+
import { extractRefs } from '@amritk/helpers/extract-refs'
|
|
3
|
+
import { refToFilename } from '@amritk/helpers/ref-to-filename'
|
|
4
|
+
import { refToName } from '@amritk/helpers/ref-to-name'
|
|
5
|
+
import { resolveDynamicRefs } from '@amritk/helpers/resolve-dynamic-refs'
|
|
6
|
+
import { resolveRef } from '@amritk/helpers/resolve-ref'
|
|
7
|
+
import { upgradeDraft07Schema } from '@amritk/helpers/upgrade-draft07-schema'
|
|
8
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
|
|
9
|
+
|
|
10
|
+
import { generateValidatorFile } from './generate-files'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Represents a generated TypeScript file with its filename and content.
|
|
14
|
+
*/
|
|
15
|
+
export type GeneratedFile = {
|
|
16
|
+
filename: string
|
|
17
|
+
content: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const VALIDATION_RESULT_CONTENT = `/**
|
|
21
|
+
* A single validation error with a human-readable message and a JSON Pointer
|
|
22
|
+
* path indicating where in the document the error occurred.
|
|
23
|
+
*/
|
|
24
|
+
export type ValidationError = {
|
|
25
|
+
message: string
|
|
26
|
+
path: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The result of a generated validator function.
|
|
31
|
+
* Returns \`true\` when the input is valid, or an object with \`valid: false\`
|
|
32
|
+
* and a list of errors when it is not.
|
|
33
|
+
*/
|
|
34
|
+
export type ValidationResult = true | { valid: false; errors: ValidationError[] }
|
|
35
|
+
`
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Builds all TypeScript validator files from a JSON Schema by traversing
|
|
39
|
+
* all $ref references recursively, mirroring the generate-parsers pipeline.
|
|
40
|
+
*
|
|
41
|
+
* Each generated file exports:
|
|
42
|
+
* - A TypeScript type definition
|
|
43
|
+
* - A `validateFoo(input: unknown, _path?: string): ValidationResult` function
|
|
44
|
+
*
|
|
45
|
+
* A `validation-result.ts` file containing the `ValidationResult` and `ValidationError`
|
|
46
|
+
* runtime contract is always emitted. An `index.ts` re-exports everything.
|
|
47
|
+
*
|
|
48
|
+
* @param rootSchema - The root JSON Schema to build from
|
|
49
|
+
* @param rootTypeName - The name for the root type (e.g. "Document")
|
|
50
|
+
* @returns An array of generated TypeScript files
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```typescript
|
|
54
|
+
* const files = await buildValidatorSchema(schema, 'Document')
|
|
55
|
+
* // files → [{ filename: 'document.ts', content: '...' }, { filename: 'info.ts', ... }, ...]
|
|
56
|
+
* ```
|
|
57
|
+
*/
|
|
58
|
+
export const buildValidatorSchema = async (rootSchema: JSONSchema, rootTypeName: string): Promise<GeneratedFile[]> => {
|
|
59
|
+
rootSchema = upgradeDraft07Schema(rootSchema as Record<string, unknown>) as JSONSchema
|
|
60
|
+
|
|
61
|
+
const files: GeneratedFile[] = []
|
|
62
|
+
const processedRefs = new Set<string>()
|
|
63
|
+
const processedFilenames = new Set<string>()
|
|
64
|
+
const refsToProcess: string[] = []
|
|
65
|
+
|
|
66
|
+
const dynamicRefMap = buildDynamicRefMap(rootSchema)
|
|
67
|
+
|
|
68
|
+
// Root schema
|
|
69
|
+
const processedRootSchema = resolveDynamicRefs(rootSchema, dynamicRefMap)
|
|
70
|
+
const rootContent = generateValidatorFile(processedRootSchema, rootTypeName, {
|
|
71
|
+
rootSchema: rootSchema as Record<string, unknown>,
|
|
72
|
+
})
|
|
73
|
+
const rootFilename = rootTypeName.toLowerCase()
|
|
74
|
+
|
|
75
|
+
if (rootFilename !== 'validation-result') {
|
|
76
|
+
processedFilenames.add(rootFilename)
|
|
77
|
+
files.push({ filename: `${rootFilename}.ts`, content: rootContent })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const rootRefs = extractRefs(rootSchema)
|
|
81
|
+
refsToProcess.push(...rootRefs)
|
|
82
|
+
|
|
83
|
+
while (refsToProcess.length > 0) {
|
|
84
|
+
const ref = refsToProcess.shift()
|
|
85
|
+
if (!ref || processedRefs.has(ref)) continue
|
|
86
|
+
processedRefs.add(ref)
|
|
87
|
+
|
|
88
|
+
const resolvedSchema = resolveRef(ref, rootSchema as Record<string, unknown>)
|
|
89
|
+
if (!resolvedSchema) {
|
|
90
|
+
console.warn(`Warning: Could not resolve ref: ${ref}`)
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const typeName = refToName(ref)
|
|
95
|
+
const filename = refToFilename(ref)
|
|
96
|
+
const processedSchema = resolveDynamicRefs(resolvedSchema as JSONSchema, dynamicRefMap)
|
|
97
|
+
const content = generateValidatorFile(processedSchema, typeName, {
|
|
98
|
+
selfRef: ref,
|
|
99
|
+
rootSchema: rootSchema as Record<string, unknown>,
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
if (filename !== 'validation-result' && !processedFilenames.has(filename)) {
|
|
103
|
+
processedFilenames.add(filename)
|
|
104
|
+
files.push({ filename: `${filename}.ts`, content })
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const nestedRef of extractRefs(resolvedSchema as JSONSchema)) {
|
|
108
|
+
if (!processedRefs.has(nestedRef)) refsToProcess.push(nestedRef)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Emit the runtime contract for validators. ValidationResult is mjst-defined
|
|
113
|
+
// (not derived from the input schema), so its content is fixed.
|
|
114
|
+
files.push({ filename: 'validation-result.ts', content: VALIDATION_RESULT_CONTENT })
|
|
115
|
+
|
|
116
|
+
// Generate index.ts
|
|
117
|
+
const TYPE_EXPORT_RE = /^export type (\w+)/gm
|
|
118
|
+
const CONST_EXPORT_RE = /^export const (\w+)/gm
|
|
119
|
+
|
|
120
|
+
const sortedFiles = [...files].sort((a, b) => a.filename.localeCompare(b.filename))
|
|
121
|
+
let indexContent = ''
|
|
122
|
+
|
|
123
|
+
for (const file of sortedFiles) {
|
|
124
|
+
const moduleName = file.filename.replace(/\.ts$/, '')
|
|
125
|
+
const typeNames: string[] = []
|
|
126
|
+
const constNames: string[] = []
|
|
127
|
+
|
|
128
|
+
for (const match of file.content.matchAll(TYPE_EXPORT_RE)) typeNames.push(match[1] as string)
|
|
129
|
+
for (const match of file.content.matchAll(CONST_EXPORT_RE)) constNames.push(match[1] as string)
|
|
130
|
+
|
|
131
|
+
if (typeNames.length === 0 && constNames.length === 0) continue
|
|
132
|
+
|
|
133
|
+
const typeExports = typeNames.map((n) => `type ${n}`)
|
|
134
|
+
indexContent += `export { ${[...typeExports, ...constNames].join(', ')} } from './${moduleName}';\n`
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
files.push({ filename: 'index.ts', content: indexContent })
|
|
138
|
+
|
|
139
|
+
return files
|
|
140
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { refToFilename } from '@amritk/helpers/ref-to-filename'
|
|
2
|
+
import { refToName } from '@amritk/helpers/ref-to-name'
|
|
3
|
+
import { resolveRef } from '@amritk/helpers/resolve-ref'
|
|
4
|
+
import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasItems, hasOneOf, hasRef } from '@amritk/helpers/schema-guards'
|
|
5
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Options for controlling how validator imports are collected.
|
|
9
|
+
*/
|
|
10
|
+
type CollectValidatorImportsOptions = {
|
|
11
|
+
/**
|
|
12
|
+
* The $ref path of the schema being generated (e.g. `#/$defs/encoding`).
|
|
13
|
+
* Prevents a file from importing itself.
|
|
14
|
+
*/
|
|
15
|
+
readonly selfRef?: string | undefined
|
|
16
|
+
/**
|
|
17
|
+
* The root schema document. URI refs that cannot be resolved within it
|
|
18
|
+
* are excluded from the import list (they were never generated as files).
|
|
19
|
+
*/
|
|
20
|
+
readonly rootSchema?: Record<string, unknown> | undefined
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Generates an import statement for a single $ref, importing both the type
|
|
25
|
+
* and the validator function from the ref's generated file.
|
|
26
|
+
*/
|
|
27
|
+
const buildImport = (ref: string): string => {
|
|
28
|
+
const filename = refToFilename(ref)
|
|
29
|
+
const typeName = refToName(ref)
|
|
30
|
+
const validatorName = `validate${typeName}`
|
|
31
|
+
return `import { type ${typeName}, ${validatorName} } from './${filename}'`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolves the canonical filename for a ref, stripping `-or-reference` suffixes
|
|
36
|
+
* so that `#/$defs/parameter-or-reference` maps to `parameter`.
|
|
37
|
+
*/
|
|
38
|
+
const canonicalFilename = (ref: string): string => {
|
|
39
|
+
const base = ref.endsWith('-or-reference') ? ref.replace('-or-reference', '') : ref
|
|
40
|
+
return refToFilename(base)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Walks one level of the schema and yields all direct $ref strings that should
|
|
45
|
+
* become imports: properties, additionalProperties, items, and union branches.
|
|
46
|
+
*/
|
|
47
|
+
const collectDirectRefs = (schema: JSONSchema): string[] => {
|
|
48
|
+
if (typeof schema === 'boolean' || schema === null) return []
|
|
49
|
+
|
|
50
|
+
const refs: string[] = []
|
|
51
|
+
|
|
52
|
+
if (hasRef(schema)) {
|
|
53
|
+
refs.push(schema.$ref)
|
|
54
|
+
return refs
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const propSchemas =
|
|
58
|
+
'properties' in schema && typeof schema.properties === 'object' && schema.properties !== null
|
|
59
|
+
? Object.values(schema.properties as Record<string, JSONSchema>)
|
|
60
|
+
: []
|
|
61
|
+
|
|
62
|
+
for (const prop of propSchemas) {
|
|
63
|
+
if (hasRef(prop)) refs.push((prop as { $ref: string }).$ref)
|
|
64
|
+
if (hasItems(prop) && hasRef(prop.items)) refs.push((prop.items as { $ref: string }).$ref)
|
|
65
|
+
if (hasAdditionalProperties(prop) && hasRef(prop.additionalProperties as JSONSchema)) {
|
|
66
|
+
refs.push((prop.additionalProperties as { $ref: string }).$ref)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (hasItems(schema) && hasRef(schema.items)) {
|
|
71
|
+
refs.push((schema.items as { $ref: string }).$ref)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (hasAdditionalProperties(schema) && hasRef(schema.additionalProperties as JSONSchema)) {
|
|
75
|
+
refs.push((schema.additionalProperties as { $ref: string }).$ref)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const branch of [
|
|
79
|
+
...(hasOneOf(schema) ? schema.oneOf : []),
|
|
80
|
+
...(hasAnyOf(schema) ? schema.anyOf : []),
|
|
81
|
+
...(hasAllOf(schema) ? schema.allOf : []),
|
|
82
|
+
]) {
|
|
83
|
+
if (hasRef(branch)) refs.push((branch as { $ref: string }).$ref)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return refs
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Collects import statements for all $ref dependencies of a schema.
|
|
91
|
+
* Each import brings in both the generated TypeScript type and validator function.
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* ```typescript
|
|
95
|
+
* const schema = { properties: { contact: { $ref: '#/$defs/contact' } } }
|
|
96
|
+
* collectValidatorImports(schema)
|
|
97
|
+
* // ["import { type ContactObject, validateContactObject } from './contact-object'"]
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
export const collectValidatorImports = (schema: JSONSchema, options?: CollectValidatorImportsOptions): string[] => {
|
|
101
|
+
const selfFilename = options?.selfRef ? refToFilename(options.selfRef) : null
|
|
102
|
+
const rootSchema = options?.rootSchema
|
|
103
|
+
|
|
104
|
+
const refs = collectDirectRefs(schema)
|
|
105
|
+
const seen = new Set<string>()
|
|
106
|
+
const imports: string[] = []
|
|
107
|
+
|
|
108
|
+
for (const ref of refs) {
|
|
109
|
+
const filename = canonicalFilename(ref)
|
|
110
|
+
|
|
111
|
+
if (seen.has(filename)) continue
|
|
112
|
+
if (selfFilename && filename === selfFilename) continue
|
|
113
|
+
|
|
114
|
+
// Skip refs that don't resolve in this schema (external / never generated)
|
|
115
|
+
if (rootSchema) {
|
|
116
|
+
const resolved = resolveRef(ref, rootSchema)
|
|
117
|
+
if (!resolved) continue
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
seen.add(filename)
|
|
121
|
+
|
|
122
|
+
// -or-reference unions import the base type's validator
|
|
123
|
+
const importRef = ref.endsWith('-or-reference') ? ref.replace('-or-reference', '') : ref
|
|
124
|
+
imports.push(buildImport(importRef))
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return imports
|
|
128
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { generateTypeDefinition } from '@amritk/helpers/generate-type-definition'
|
|
2
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
|
|
3
|
+
|
|
4
|
+
import { collectValidatorImports } from './collect-validator-imports'
|
|
5
|
+
import { generateValidatorFunction } from './generate-validator-function'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Options for controlling what gets generated in a validator file.
|
|
9
|
+
*/
|
|
10
|
+
type GenerateValidatorFileOptions = {
|
|
11
|
+
/**
|
|
12
|
+
* The $ref path of the schema being generated (e.g. `#/$defs/info`).
|
|
13
|
+
* Prevents the file from importing itself.
|
|
14
|
+
*/
|
|
15
|
+
readonly selfRef?: string
|
|
16
|
+
/**
|
|
17
|
+
* The root schema document. Used to filter out unresolvable refs.
|
|
18
|
+
*/
|
|
19
|
+
readonly rootSchema?: Record<string, unknown>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Generates a complete TypeScript validator file from a JSON Schema.
|
|
24
|
+
*
|
|
25
|
+
* The file contains:
|
|
26
|
+
* - Imports for the ValidationResult/ValidationError types
|
|
27
|
+
* - Imports for any $ref types and their validator functions
|
|
28
|
+
* - The exported TypeScript type definition
|
|
29
|
+
* - The exported validator function
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```typescript
|
|
33
|
+
* const schema = {
|
|
34
|
+
* type: 'object',
|
|
35
|
+
* properties: { title: { type: 'string' } },
|
|
36
|
+
* required: ['title'],
|
|
37
|
+
* }
|
|
38
|
+
* generateValidatorFile(schema, 'Info')
|
|
39
|
+
* // import type { ValidationResult, ValidationError } from './validation-result'
|
|
40
|
+
* // export type Info = { title: string }
|
|
41
|
+
* // export const validateInfo = (input: unknown, _path = ''): ValidationResult => { ... }
|
|
42
|
+
* ```
|
|
43
|
+
*/
|
|
44
|
+
export const generateValidatorFile = (
|
|
45
|
+
schema: JSONSchema,
|
|
46
|
+
typeName: string,
|
|
47
|
+
options?: GenerateValidatorFileOptions,
|
|
48
|
+
): string => {
|
|
49
|
+
const refImports = collectValidatorImports(schema, {
|
|
50
|
+
selfRef: options?.selfRef,
|
|
51
|
+
rootSchema: options?.rootSchema,
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const typeDefinition = generateTypeDefinition(schema, typeName)
|
|
55
|
+
const validatorFunction = generateValidatorFunction(schema, typeName)
|
|
56
|
+
|
|
57
|
+
let result = `import type { ValidationResult, ValidationError } from './validation-result'\n`
|
|
58
|
+
|
|
59
|
+
for (const imp of refImports) {
|
|
60
|
+
result += imp + '\n'
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (refImports.length > 0) {
|
|
64
|
+
result += '\n'
|
|
65
|
+
} else {
|
|
66
|
+
result += '\n'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
result += typeDefinition + '\n\n' + validatorFunction
|
|
70
|
+
|
|
71
|
+
return result
|
|
72
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { generateValidatorFunction } from './generate-validator-function'
|
|
4
|
+
|
|
5
|
+
// Eval helper: compiles a generated function string in context of a minimal
|
|
6
|
+
// ValidationResult runtime so we can actually run the generated code.
|
|
7
|
+
const _evalValidator = (code: string): ((input: unknown, path?: string) => unknown) => {
|
|
8
|
+
const _wrapped = `
|
|
9
|
+
const ValidationResult = null // type-only
|
|
10
|
+
${code}
|
|
11
|
+
`
|
|
12
|
+
// eslint-disable-next-line no-new-func
|
|
13
|
+
return new Function(`
|
|
14
|
+
${code}
|
|
15
|
+
return validate${code.match(/export const validate(\w+)/)?.[1] ?? ''}
|
|
16
|
+
`)() as (input: unknown, path?: string) => unknown
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('generate-validator-function', () => {
|
|
20
|
+
it('generates a validator for a required string property', () => {
|
|
21
|
+
const schema = {
|
|
22
|
+
type: 'object' as const,
|
|
23
|
+
properties: { name: { type: 'string' as const } },
|
|
24
|
+
required: ['name'],
|
|
25
|
+
}
|
|
26
|
+
const code = generateValidatorFunction(schema, 'Info')
|
|
27
|
+
|
|
28
|
+
expect(code).toContain('export const validateInfo')
|
|
29
|
+
expect(code).toContain('"name" in obj')
|
|
30
|
+
expect(code).toContain("must have required property 'name'")
|
|
31
|
+
expect(code).toContain('typeof obj["name"] !== \'string\'')
|
|
32
|
+
expect(code).toContain('must be string')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('generates a validator for an optional number property', () => {
|
|
36
|
+
const schema = {
|
|
37
|
+
type: 'object' as const,
|
|
38
|
+
properties: { count: { type: 'number' as const } },
|
|
39
|
+
}
|
|
40
|
+
const code = generateValidatorFunction(schema, 'Stats')
|
|
41
|
+
|
|
42
|
+
expect(code).toContain('obj["count"] !== undefined')
|
|
43
|
+
expect(code).toContain('typeof obj["count"] !== \'number\'')
|
|
44
|
+
expect(code).toContain('must be number')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('generates a validator for a boolean property', () => {
|
|
48
|
+
const schema = {
|
|
49
|
+
type: 'object' as const,
|
|
50
|
+
properties: { enabled: { type: 'boolean' as const } },
|
|
51
|
+
required: ['enabled'],
|
|
52
|
+
}
|
|
53
|
+
const code = generateValidatorFunction(schema, 'Config')
|
|
54
|
+
|
|
55
|
+
expect(code).toContain('typeof obj["enabled"] !== \'boolean\'')
|
|
56
|
+
expect(code).toContain('must be boolean')
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('generates required-property check at parent path', () => {
|
|
60
|
+
const schema = {
|
|
61
|
+
type: 'object' as const,
|
|
62
|
+
properties: { title: { type: 'string' as const } },
|
|
63
|
+
required: ['title'],
|
|
64
|
+
}
|
|
65
|
+
const code = generateValidatorFunction(schema, 'Doc')
|
|
66
|
+
|
|
67
|
+
// Required errors use _path (parent), not a child path
|
|
68
|
+
expect(code).toContain('path: _path')
|
|
69
|
+
expect(code).toContain("must have required property 'title'")
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('generates type error at child path', () => {
|
|
73
|
+
const schema = {
|
|
74
|
+
type: 'object' as const,
|
|
75
|
+
properties: { title: { type: 'string' as const } },
|
|
76
|
+
required: ['title'],
|
|
77
|
+
}
|
|
78
|
+
const code = generateValidatorFunction(schema, 'Doc')
|
|
79
|
+
|
|
80
|
+
// Type errors use the child path
|
|
81
|
+
expect(code).toContain('`${_path}/title`')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('generates an enum validator', () => {
|
|
85
|
+
const schema = {
|
|
86
|
+
enum: ['get', 'post', 'put', 'delete'],
|
|
87
|
+
}
|
|
88
|
+
const code = generateValidatorFunction(schema as Parameters<typeof generateValidatorFunction>[0], 'Method')
|
|
89
|
+
|
|
90
|
+
expect(code).toContain('must be one of')
|
|
91
|
+
expect(code).toContain('"get"')
|
|
92
|
+
expect(code).toContain('"post"')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('generates a string pattern check', () => {
|
|
96
|
+
const schema = {
|
|
97
|
+
type: 'object' as const,
|
|
98
|
+
properties: { version: { type: 'string' as const, pattern: '^\\d+\\.\\d+' } },
|
|
99
|
+
required: ['version'],
|
|
100
|
+
}
|
|
101
|
+
const code = generateValidatorFunction(schema, 'Info')
|
|
102
|
+
|
|
103
|
+
expect(code).toContain('must match pattern')
|
|
104
|
+
expect(code).toContain('.test(')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('generates min/maxLength checks', () => {
|
|
108
|
+
const schema = {
|
|
109
|
+
type: 'object' as const,
|
|
110
|
+
properties: { name: { type: 'string' as const, minLength: 1, maxLength: 100 } },
|
|
111
|
+
required: ['name'],
|
|
112
|
+
}
|
|
113
|
+
const code = generateValidatorFunction(schema, 'Info')
|
|
114
|
+
|
|
115
|
+
expect(code).toContain('must have at least 1 characters')
|
|
116
|
+
expect(code).toContain('must have at most 100 characters')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('generates minimum/maximum checks', () => {
|
|
120
|
+
const schema = {
|
|
121
|
+
type: 'object' as const,
|
|
122
|
+
properties: { port: { type: 'number' as const, minimum: 0, maximum: 65535 } },
|
|
123
|
+
}
|
|
124
|
+
const code = generateValidatorFunction(schema, 'Server')
|
|
125
|
+
|
|
126
|
+
expect(code).toContain('>= 0')
|
|
127
|
+
expect(code).toContain('<= 65535')
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('generates a $ref property delegation', () => {
|
|
131
|
+
const schema = {
|
|
132
|
+
type: 'object' as const,
|
|
133
|
+
properties: { info: { $ref: '#/$defs/info' } },
|
|
134
|
+
required: ['info'],
|
|
135
|
+
}
|
|
136
|
+
const code = generateValidatorFunction(schema, 'Document')
|
|
137
|
+
|
|
138
|
+
expect(code).toContain('validateInfoObject(')
|
|
139
|
+
expect(code).toContain('"info" in obj')
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('generates a scalar string validator', () => {
|
|
143
|
+
const schema = { type: 'string' as const }
|
|
144
|
+
const code = generateValidatorFunction(schema, 'StringValue')
|
|
145
|
+
|
|
146
|
+
expect(code).toContain("typeof input !== 'string'")
|
|
147
|
+
expect(code).toContain('must be string')
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('returns true for empty object schemas', () => {
|
|
151
|
+
const schema = { type: 'object' as const }
|
|
152
|
+
const code = generateValidatorFunction(schema, 'Empty')
|
|
153
|
+
|
|
154
|
+
expect(code).toContain('validateEmpty')
|
|
155
|
+
expect(code).toContain('must be object')
|
|
156
|
+
expect(code).toContain('return errors.length > 0')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('generates object guard at top of object validator', () => {
|
|
160
|
+
const schema = { type: 'object' as const, properties: { x: { type: 'string' as const } } }
|
|
161
|
+
const code = generateValidatorFunction(schema, 'Foo')
|
|
162
|
+
|
|
163
|
+
expect(code).toContain("typeof input !== 'object'")
|
|
164
|
+
expect(code).toContain('Array.isArray(input)')
|
|
165
|
+
expect(code).toContain('must be object')
|
|
166
|
+
})
|
|
167
|
+
})
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { refToName } from '@amritk/helpers/ref-to-name'
|
|
2
|
+
import {
|
|
3
|
+
hasAdditionalProperties,
|
|
4
|
+
hasEnum,
|
|
5
|
+
hasExclusiveMaximum,
|
|
6
|
+
hasExclusiveMinimum,
|
|
7
|
+
hasItems,
|
|
8
|
+
hasMaximum,
|
|
9
|
+
hasMaxLength,
|
|
10
|
+
hasMinimum,
|
|
11
|
+
hasMinLength,
|
|
12
|
+
hasMultipleOf,
|
|
13
|
+
hasOneOf,
|
|
14
|
+
hasPattern,
|
|
15
|
+
hasProperties,
|
|
16
|
+
hasRef,
|
|
17
|
+
hasRequired,
|
|
18
|
+
hasType,
|
|
19
|
+
isObjectSchema,
|
|
20
|
+
isSchemaObject,
|
|
21
|
+
} from '@amritk/helpers/schema-guards'
|
|
22
|
+
import type { JSONSchema } from 'json-schema-typed/draft-2020-12'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Derives the validator function name from a type name.
|
|
26
|
+
* e.g. "InfoObject" → "validateInfoObject"
|
|
27
|
+
*/
|
|
28
|
+
const validatorName = (typeName: string): string => `validate${typeName}`
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Returns the TypeScript typeof string for a JSON Schema primitive type.
|
|
32
|
+
*/
|
|
33
|
+
const typeofString = (type: string): string => {
|
|
34
|
+
if (type === 'integer') return 'number'
|
|
35
|
+
return type
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Generates the inline condition that is TRUE when a value is the wrong type.
|
|
40
|
+
*/
|
|
41
|
+
const wrongTypeCondition = (accessor: string, type: string): string => {
|
|
42
|
+
switch (type) {
|
|
43
|
+
case 'string':
|
|
44
|
+
return `typeof ${accessor} !== 'string'`
|
|
45
|
+
case 'number':
|
|
46
|
+
case 'integer':
|
|
47
|
+
return `typeof ${accessor} !== 'number'`
|
|
48
|
+
case 'boolean':
|
|
49
|
+
return `typeof ${accessor} !== 'boolean'`
|
|
50
|
+
case 'array':
|
|
51
|
+
return `!Array.isArray(${accessor})`
|
|
52
|
+
case 'object':
|
|
53
|
+
return `typeof ${accessor} !== 'object' || ${accessor} === null || Array.isArray(${accessor})`
|
|
54
|
+
default:
|
|
55
|
+
return ''
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Generates validation lines for a single property in an object schema.
|
|
61
|
+
* Handles $ref delegation, enum checks, type checks, and string/number constraints.
|
|
62
|
+
*/
|
|
63
|
+
const generatePropertyChecks = (key: string, propSchema: JSONSchema, isRequired: boolean): string[] => {
|
|
64
|
+
if (!isSchemaObject(propSchema)) return []
|
|
65
|
+
|
|
66
|
+
const raw = `obj[${JSON.stringify(key)}]`
|
|
67
|
+
const path = `\`\${_path}/${key}\``
|
|
68
|
+
const lines: string[] = []
|
|
69
|
+
|
|
70
|
+
// $ref — delegate to the imported validator
|
|
71
|
+
if (hasRef(propSchema)) {
|
|
72
|
+
const ref = propSchema.$ref
|
|
73
|
+
const vName = validatorName(refToName(ref))
|
|
74
|
+
|
|
75
|
+
if (isRequired) {
|
|
76
|
+
lines.push(` if (!(${JSON.stringify(key)} in obj)) {`)
|
|
77
|
+
lines.push(` errors.push({ message: "must have required property '${key}'", path: _path })`)
|
|
78
|
+
lines.push(` } else {`)
|
|
79
|
+
lines.push(` const _r = ${vName}(${raw}, ${path})`)
|
|
80
|
+
lines.push(` if (_r !== true) errors.push(..._r.errors)`)
|
|
81
|
+
lines.push(` }`)
|
|
82
|
+
} else {
|
|
83
|
+
lines.push(` if (${raw} !== undefined) {`)
|
|
84
|
+
lines.push(` const _r = ${vName}(${raw}, ${path})`)
|
|
85
|
+
lines.push(` if (_r !== true) errors.push(..._r.errors)`)
|
|
86
|
+
lines.push(` }`)
|
|
87
|
+
}
|
|
88
|
+
return lines
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// enum
|
|
92
|
+
if (hasEnum(propSchema)) {
|
|
93
|
+
const allowed = JSON.stringify(propSchema.enum)
|
|
94
|
+
const label = (propSchema.enum as unknown[]).map((v) => JSON.stringify(v)).join(', ')
|
|
95
|
+
|
|
96
|
+
if (isRequired) {
|
|
97
|
+
lines.push(` if (!(${JSON.stringify(key)} in obj)) {`)
|
|
98
|
+
lines.push(` errors.push({ message: "must have required property '${key}'", path: _path })`)
|
|
99
|
+
lines.push(` } else if (!(${allowed} as unknown[]).includes(${raw})) {`)
|
|
100
|
+
lines.push(` errors.push({ message: \`must be one of: ${label}\`, path: ${path} })`)
|
|
101
|
+
lines.push(` }`)
|
|
102
|
+
} else {
|
|
103
|
+
lines.push(` if (${raw} !== undefined && !(${allowed} as unknown[]).includes(${raw})) {`)
|
|
104
|
+
lines.push(` errors.push({ message: \`must be one of: ${label}\`, path: ${path} })`)
|
|
105
|
+
lines.push(` }`)
|
|
106
|
+
}
|
|
107
|
+
return lines
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// typed property
|
|
111
|
+
if (hasType(propSchema)) {
|
|
112
|
+
const t = propSchema.type as string
|
|
113
|
+
const wrongType = wrongTypeCondition(raw, t)
|
|
114
|
+
const typLabel = typeofString(t)
|
|
115
|
+
|
|
116
|
+
if (isRequired) {
|
|
117
|
+
lines.push(` if (!(${JSON.stringify(key)} in obj)) {`)
|
|
118
|
+
lines.push(` errors.push({ message: "must have required property '${key}'", path: _path })`)
|
|
119
|
+
if (wrongType) {
|
|
120
|
+
lines.push(` } else if (${wrongType}) {`)
|
|
121
|
+
lines.push(` errors.push({ message: 'must be ${typLabel}', path: ${path} })`)
|
|
122
|
+
}
|
|
123
|
+
lines.push(` }`)
|
|
124
|
+
} else if (wrongType) {
|
|
125
|
+
lines.push(` if (${raw} !== undefined && (${wrongType})) {`)
|
|
126
|
+
lines.push(` errors.push({ message: 'must be ${typLabel}', path: ${path} })`)
|
|
127
|
+
lines.push(` }`)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// String constraints
|
|
131
|
+
if (t === 'string') {
|
|
132
|
+
if (hasPattern(propSchema)) {
|
|
133
|
+
lines.push(` if (typeof ${raw} === 'string' && !/${propSchema.pattern}/.test(${raw})) {`)
|
|
134
|
+
lines.push(` errors.push({ message: 'must match pattern ${propSchema.pattern}', path: ${path} })`)
|
|
135
|
+
lines.push(` }`)
|
|
136
|
+
}
|
|
137
|
+
if (hasMinLength(propSchema)) {
|
|
138
|
+
lines.push(` if (typeof ${raw} === 'string' && ${raw}.length < ${propSchema.minLength}) {`)
|
|
139
|
+
lines.push(
|
|
140
|
+
` errors.push({ message: 'must have at least ${propSchema.minLength} characters', path: ${path} })`,
|
|
141
|
+
)
|
|
142
|
+
lines.push(` }`)
|
|
143
|
+
}
|
|
144
|
+
if (hasMaxLength(propSchema)) {
|
|
145
|
+
lines.push(` if (typeof ${raw} === 'string' && ${raw}.length > ${propSchema.maxLength}) {`)
|
|
146
|
+
lines.push(
|
|
147
|
+
` errors.push({ message: 'must have at most ${propSchema.maxLength} characters', path: ${path} })`,
|
|
148
|
+
)
|
|
149
|
+
lines.push(` }`)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Number constraints
|
|
154
|
+
if (t === 'number' || t === 'integer') {
|
|
155
|
+
if (hasMinimum(propSchema)) {
|
|
156
|
+
lines.push(` if (typeof ${raw} === 'number' && ${raw} < ${propSchema.minimum}) {`)
|
|
157
|
+
lines.push(` errors.push({ message: 'must be >= ${propSchema.minimum}', path: ${path} })`)
|
|
158
|
+
lines.push(` }`)
|
|
159
|
+
}
|
|
160
|
+
if (hasMaximum(propSchema)) {
|
|
161
|
+
lines.push(` if (typeof ${raw} === 'number' && ${raw} > ${propSchema.maximum}) {`)
|
|
162
|
+
lines.push(` errors.push({ message: 'must be <= ${propSchema.maximum}', path: ${path} })`)
|
|
163
|
+
lines.push(` }`)
|
|
164
|
+
}
|
|
165
|
+
if (hasExclusiveMinimum(propSchema)) {
|
|
166
|
+
lines.push(` if (typeof ${raw} === 'number' && ${raw} <= ${propSchema.exclusiveMinimum}) {`)
|
|
167
|
+
lines.push(` errors.push({ message: 'must be > ${propSchema.exclusiveMinimum}', path: ${path} })`)
|
|
168
|
+
lines.push(` }`)
|
|
169
|
+
}
|
|
170
|
+
if (hasExclusiveMaximum(propSchema)) {
|
|
171
|
+
lines.push(` if (typeof ${raw} === 'number' && ${raw} >= ${propSchema.exclusiveMaximum}) {`)
|
|
172
|
+
lines.push(` errors.push({ message: 'must be < ${propSchema.exclusiveMaximum}', path: ${path} })`)
|
|
173
|
+
lines.push(` }`)
|
|
174
|
+
}
|
|
175
|
+
if (hasMultipleOf(propSchema)) {
|
|
176
|
+
lines.push(` if (typeof ${raw} === 'number' && ${raw} % ${propSchema.multipleOf} !== 0) {`)
|
|
177
|
+
lines.push(` errors.push({ message: 'must be a multiple of ${propSchema.multipleOf}', path: ${path} })`)
|
|
178
|
+
lines.push(` }`)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Array with typed items
|
|
183
|
+
if (t === 'array' && hasItems(propSchema)) {
|
|
184
|
+
const itemSchema = propSchema.items
|
|
185
|
+
if (hasRef(itemSchema)) {
|
|
186
|
+
const vName = validatorName(refToName(itemSchema.$ref))
|
|
187
|
+
lines.push(` if (Array.isArray(${raw})) {`)
|
|
188
|
+
lines.push(` for (let _i = 0; _i < ${raw}.length; _i++) {`)
|
|
189
|
+
lines.push(` const _ir = ${vName}(${raw}[_i], \`${path.slice(1, -1)}/${key}/\${_i}\`)`)
|
|
190
|
+
lines.push(` if (_ir !== true) errors.push(..._ir.errors)`)
|
|
191
|
+
lines.push(` }`)
|
|
192
|
+
lines.push(` }`)
|
|
193
|
+
} else if (hasType(itemSchema)) {
|
|
194
|
+
const itemType = itemSchema.type as string
|
|
195
|
+
const itemWrong = wrongTypeCondition('_item', itemType)
|
|
196
|
+
const itemLabel = typeofString(itemType)
|
|
197
|
+
if (itemWrong) {
|
|
198
|
+
lines.push(` if (Array.isArray(${raw})) {`)
|
|
199
|
+
lines.push(` for (let _i = 0; _i < ${raw}.length; _i++) {`)
|
|
200
|
+
lines.push(` const _item = ${raw}[_i]`)
|
|
201
|
+
lines.push(
|
|
202
|
+
` if (${itemWrong}) errors.push({ message: 'items must be ${itemLabel}', path: \`${path.slice(1, -1)}/${key}/\${_i}\` })`,
|
|
203
|
+
)
|
|
204
|
+
lines.push(` }`)
|
|
205
|
+
lines.push(` }`)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return lines
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Generates a validator function body for an object schema, checking each
|
|
216
|
+
* property's presence and type and collecting all errors.
|
|
217
|
+
*/
|
|
218
|
+
const generateObjectValidator = (schema: JSONSchema, typeName: string): string => {
|
|
219
|
+
const vName = validatorName(typeName)
|
|
220
|
+
const required = new Set(hasRequired(schema) ? schema.required : [])
|
|
221
|
+
const properties = hasProperties(schema) ? schema.properties : {}
|
|
222
|
+
|
|
223
|
+
const propertyLines: string[] = []
|
|
224
|
+
|
|
225
|
+
for (const [key, propSchema] of Object.entries(properties)) {
|
|
226
|
+
const checks = generatePropertyChecks(key, propSchema as JSONSchema, required.has(key))
|
|
227
|
+
if (checks.length > 0) {
|
|
228
|
+
propertyLines.push(...checks)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// additionalProperties with a $ref schema validates all extra keys
|
|
233
|
+
if (
|
|
234
|
+
hasAdditionalProperties(schema) &&
|
|
235
|
+
isSchemaObject(schema.additionalProperties) &&
|
|
236
|
+
hasRef(schema.additionalProperties)
|
|
237
|
+
) {
|
|
238
|
+
const vRefName = validatorName(refToName(schema.additionalProperties.$ref))
|
|
239
|
+
propertyLines.push(` for (const _key of Object.keys(obj)) {`)
|
|
240
|
+
propertyLines.push(` if (${JSON.stringify(Object.keys(properties))}.includes(_key)) continue`)
|
|
241
|
+
propertyLines.push(` const _r = ${vRefName}(obj[_key as keyof typeof obj], \`\${_path}/\${_key}\`)`)
|
|
242
|
+
propertyLines.push(` if (_r !== true) errors.push(..._r.errors)`)
|
|
243
|
+
propertyLines.push(` }`)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const body = propertyLines.length > 0 ? '\n' + propertyLines.join('\n') + '\n' : ''
|
|
247
|
+
|
|
248
|
+
return [
|
|
249
|
+
`export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
|
|
250
|
+
` if (typeof input !== 'object' || input === null || Array.isArray(input)) {`,
|
|
251
|
+
` return { valid: false, errors: [{ message: 'must be object', path: _path }] }`,
|
|
252
|
+
` }`,
|
|
253
|
+
``,
|
|
254
|
+
` const errors: ValidationError[] = []`,
|
|
255
|
+
` const obj = input as Record<string, unknown>`,
|
|
256
|
+
body,
|
|
257
|
+
` return errors.length > 0 ? { valid: false, errors } : true`,
|
|
258
|
+
`}`,
|
|
259
|
+
].join('\n')
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Generates a validator function for a non-object schema (primitive, array, enum, $ref).
|
|
264
|
+
*/
|
|
265
|
+
const generateScalarValidator = (schema: JSONSchema, typeName: string): string => {
|
|
266
|
+
const vName = validatorName(typeName)
|
|
267
|
+
|
|
268
|
+
if (!isSchemaObject(schema)) {
|
|
269
|
+
return [`export const ${vName} = (_input: unknown, _path = ''): ValidationResult => {`, ` return true`, `}`].join(
|
|
270
|
+
'\n',
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Top-level $ref — delegate entirely
|
|
275
|
+
if (hasRef(schema)) {
|
|
276
|
+
const delegateName = validatorName(refToName(schema.$ref))
|
|
277
|
+
return [
|
|
278
|
+
`export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
|
|
279
|
+
` return ${delegateName}(input, _path)`,
|
|
280
|
+
`}`,
|
|
281
|
+
].join('\n')
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Top-level enum
|
|
285
|
+
if (hasEnum(schema)) {
|
|
286
|
+
const allowed = JSON.stringify(schema.enum)
|
|
287
|
+
const label = (schema.enum as unknown[]).map((v) => JSON.stringify(v)).join(', ')
|
|
288
|
+
return [
|
|
289
|
+
`export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
|
|
290
|
+
` if (!(${allowed} as unknown[]).includes(input)) {`,
|
|
291
|
+
` return { valid: false, errors: [{ message: \`must be one of: ${label}\`, path: _path }] }`,
|
|
292
|
+
` }`,
|
|
293
|
+
` return true`,
|
|
294
|
+
`}`,
|
|
295
|
+
].join('\n')
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// oneOf — try each branch, return errors from all if none match
|
|
299
|
+
if (hasOneOf(schema)) {
|
|
300
|
+
const branches = schema.oneOf
|
|
301
|
+
.map((branch, i) => {
|
|
302
|
+
if (!hasRef(branch)) return null
|
|
303
|
+
const bName = validatorName(refToName((branch as { $ref: string }).$ref))
|
|
304
|
+
return ` const _r${i} = ${bName}(input, _path)\n if (_r${i} === true) return true`
|
|
305
|
+
})
|
|
306
|
+
.filter(Boolean)
|
|
307
|
+
.join('\n')
|
|
308
|
+
|
|
309
|
+
return [
|
|
310
|
+
`export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
|
|
311
|
+
branches,
|
|
312
|
+
` return { valid: false, errors: [{ message: 'must match one of the expected schemas', path: _path }] }`,
|
|
313
|
+
`}`,
|
|
314
|
+
].join('\n')
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Top-level typed schema (string, number, boolean, array)
|
|
318
|
+
if (hasType(schema)) {
|
|
319
|
+
const t = schema.type as string
|
|
320
|
+
const wrongType = wrongTypeCondition('input', t)
|
|
321
|
+
const typLabel = typeofString(t)
|
|
322
|
+
|
|
323
|
+
const constraintLines: string[] = []
|
|
324
|
+
|
|
325
|
+
if (t === 'string') {
|
|
326
|
+
if (hasPattern(schema)) {
|
|
327
|
+
constraintLines.push(` if (typeof input === 'string' && !/${schema.pattern}/.test(input)) {`)
|
|
328
|
+
constraintLines.push(
|
|
329
|
+
` return { valid: false, errors: [{ message: 'must match pattern ${schema.pattern}', path: _path }] }`,
|
|
330
|
+
)
|
|
331
|
+
constraintLines.push(` }`)
|
|
332
|
+
}
|
|
333
|
+
if (hasMinLength(schema)) {
|
|
334
|
+
constraintLines.push(` if (typeof input === 'string' && input.length < ${schema.minLength}) {`)
|
|
335
|
+
constraintLines.push(
|
|
336
|
+
` return { valid: false, errors: [{ message: 'must have at least ${schema.minLength} characters', path: _path }] }`,
|
|
337
|
+
)
|
|
338
|
+
constraintLines.push(` }`)
|
|
339
|
+
}
|
|
340
|
+
if (hasMaxLength(schema)) {
|
|
341
|
+
constraintLines.push(` if (typeof input === 'string' && input.length > ${schema.maxLength}) {`)
|
|
342
|
+
constraintLines.push(
|
|
343
|
+
` return { valid: false, errors: [{ message: 'must have at most ${schema.maxLength} characters', path: _path }] }`,
|
|
344
|
+
)
|
|
345
|
+
constraintLines.push(` }`)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const body = constraintLines.length > 0 ? '\n' + constraintLines.join('\n') + '\n' : ''
|
|
350
|
+
|
|
351
|
+
return wrongType
|
|
352
|
+
? [
|
|
353
|
+
`export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
|
|
354
|
+
` if (${wrongType}) {`,
|
|
355
|
+
` return { valid: false, errors: [{ message: 'must be ${typLabel}', path: _path }] }`,
|
|
356
|
+
` }`,
|
|
357
|
+
body,
|
|
358
|
+
` return true`,
|
|
359
|
+
`}`,
|
|
360
|
+
].join('\n')
|
|
361
|
+
: [`export const ${vName} = (_input: unknown, _path = ''): ValidationResult => {`, ` return true`, `}`].join(
|
|
362
|
+
'\n',
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return [`export const ${vName} = (_input: unknown, _path = ''): ValidationResult => {`, ` return true`, `}`].join(
|
|
367
|
+
'\n',
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* Generates a TypeScript validator function from a JSON Schema.
|
|
373
|
+
*
|
|
374
|
+
* The generated function accepts `unknown` input and returns `true` if valid,
|
|
375
|
+
* or `{ valid: false, errors }` with a list of errors if not.
|
|
376
|
+
*
|
|
377
|
+
* Object schemas check that required properties are present and that all
|
|
378
|
+
* provided properties match their declared types. Non-object schemas (strings,
|
|
379
|
+
* numbers, enums, $refs) emit an inline type check.
|
|
380
|
+
*
|
|
381
|
+
* @example
|
|
382
|
+
* ```typescript
|
|
383
|
+
* generateValidatorFunction({ type: 'object', properties: { name: { type: 'string' } }, required: ['name'] }, 'Info')
|
|
384
|
+
* // export const validateInfo = (input: unknown, _path = ''): ValidationResult => {
|
|
385
|
+
* // if (typeof input !== 'object' || ...) return { valid: false, ... }
|
|
386
|
+
* // const errors: ValidationError[] = []
|
|
387
|
+
* // const obj = input as Record<string, unknown>
|
|
388
|
+
* // if (!('name' in obj)) { errors.push(...) } else if (typeof obj['name'] !== 'string') { errors.push(...) }
|
|
389
|
+
* // return errors.length > 0 ? { valid: false, errors } : true
|
|
390
|
+
* // }
|
|
391
|
+
* ```
|
|
392
|
+
*/
|
|
393
|
+
export const generateValidatorFunction = (schema: JSONSchema, typeName: string): string => {
|
|
394
|
+
if (isObjectSchema(schema)) {
|
|
395
|
+
return generateObjectValidator(schema, typeName)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return generateScalarValidator(schema, typeName)
|
|
399
|
+
}
|
package/src/index.ts
ADDED