@amritk/generate-validators 0.5.1 → 0.7.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 +24 -0
- package/dist/generators/build-schema.d.ts +0 -1
- package/dist/generators/build-schema.js +29 -0
- package/dist/generators/collect-validator-imports.d.ts +0 -1
- package/dist/generators/collect-validator-imports.js +5 -1
- package/dist/generators/generate-files.d.ts +0 -1
- package/dist/generators/generate-files.js +6 -0
- package/dist/generators/generate-validator-function.d.ts +0 -1
- package/dist/generators/generate-validator-function.js +226 -27
- package/dist/index.d.ts +0 -1
- package/package.json +10 -7
- package/dist/generators/build-schema.d.ts.map +0 -1
- package/dist/generators/collect-validator-imports.d.ts.map +0 -1
- package/dist/generators/generate-files.d.ts.map +0 -1
- package/dist/generators/generate-validator-function.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/src/generators/build-schema.test.ts +0 -163
- package/src/generators/build-schema.ts +0 -81
- package/src/generators/collect-validator-imports.ts +0 -134
- package/src/generators/generate-files.ts +0 -79
- package/src/generators/generate-validator-function.test.ts +0 -238
- package/src/generators/generate-validator-function.ts +0 -472
- package/src/index.ts +0 -2
package/README.md
CHANGED
|
@@ -92,6 +92,30 @@ Returns: `Promise<GeneratedFile[]>` where `GeneratedFile = { filename: string; c
|
|
|
92
92
|
|
|
93
93
|
---
|
|
94
94
|
|
|
95
|
+
## Benchmarks
|
|
96
|
+
|
|
97
|
+
Generated validators are straight-line, monomorphic TypeScript with no generic
|
|
98
|
+
dispatch, so they validate as fast as an Ajv-compiled function once emitted —
|
|
99
|
+
and emitting them is far cheaper than compiling a schema at startup. Measured
|
|
100
|
+
on Bun 1.3 (Linux x64), validating valid input at steady state:
|
|
101
|
+
|
|
102
|
+
| schema | mjst (generated) | ajv (compiled) | typebox (compiled) | zod |
|
|
103
|
+
|:--|--:|--:|--:|--:|
|
|
104
|
+
| small (4 fields) | **~9.5M** ops/s | ~9.3M ops/s | ~4.8M ops/s | ~1.7M ops/s |
|
|
105
|
+
| order (nested + array) | **~3.7M** ops/s | ~3.5M ops/s | ~2.0M ops/s | ~0.4M ops/s |
|
|
106
|
+
|
|
107
|
+
Preparing a validator costs ~0.15–0.20 ms for mjst codegen and ~0.12–0.21 ms for
|
|
108
|
+
a TypeBox `TypeCompiler` compile, versus ~13–14 ms for an Ajv compile. All four
|
|
109
|
+
libraries agree on every verdict; parity is asserted before timing (TypeBox is
|
|
110
|
+
given uuid/email format checkers so every library does the same work).
|
|
111
|
+
Micro-benchmark figures vary by machine and runtime — reproduce with:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
bun run bench
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
---
|
|
118
|
+
|
|
95
119
|
## Related packages
|
|
96
120
|
|
|
97
121
|
- [`@amritk/generate-parsers`](../generate-parsers) — type definitions plus parsers that coerce input
|
|
@@ -16,6 +16,35 @@ export type ValidationError = {
|
|
|
16
16
|
* and a list of errors when it is not.
|
|
17
17
|
*/
|
|
18
18
|
export type ValidationResult = true | { valid: false; errors: ValidationError[] }
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Structural deep equality used by generated \`const\` checks. Objects compare by
|
|
22
|
+
* their key sets rather than serialization, so \`{ a: 1, b: 2 }\` and
|
|
23
|
+
* \`{ b: 2, a: 1 }\` are equal — unlike \`JSON.stringify\`, which is key-order
|
|
24
|
+
* sensitive and would reject a reordered-but-equal value.
|
|
25
|
+
*/
|
|
26
|
+
export const valuesEqual = (a: unknown, b: unknown): boolean => {
|
|
27
|
+
if (a === b) return true
|
|
28
|
+
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
|
|
29
|
+
const aArray = Array.isArray(a)
|
|
30
|
+
const bArray = Array.isArray(b)
|
|
31
|
+
if (aArray !== bArray) return false
|
|
32
|
+
if (aArray) {
|
|
33
|
+
const aa = a as unknown[]
|
|
34
|
+
const bb = b as unknown[]
|
|
35
|
+
if (aa.length !== bb.length) return false
|
|
36
|
+
for (let i = 0; i < aa.length; i++) if (!valuesEqual(aa[i], bb[i])) return false
|
|
37
|
+
return true
|
|
38
|
+
}
|
|
39
|
+
const ao = a as Record<string, unknown>
|
|
40
|
+
const bo = b as Record<string, unknown>
|
|
41
|
+
const keys = Object.keys(ao)
|
|
42
|
+
if (keys.length !== Object.keys(bo).length) return false
|
|
43
|
+
for (const key of keys) {
|
|
44
|
+
if (!Object.hasOwn(bo, key) || !valuesEqual(ao[key], bo[key])) return false
|
|
45
|
+
}
|
|
46
|
+
return true
|
|
47
|
+
}
|
|
19
48
|
`;
|
|
20
49
|
/**
|
|
21
50
|
* Builds all TypeScript validator files from a JSON Schema by traversing all
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { refToFilename } from '@amritk/helpers/ref-to-filename';
|
|
2
2
|
import { refToName } from '@amritk/helpers/ref-to-name';
|
|
3
3
|
import { resolveRef } from '@amritk/helpers/resolve-ref';
|
|
4
|
-
import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasItems, hasOneOf, hasRef } from '@amritk/helpers/schema-guards';
|
|
4
|
+
import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasItems, hasOneOf, hasProperties, hasRef, } from '@amritk/helpers/schema-guards';
|
|
5
5
|
/**
|
|
6
6
|
* Generates an import statement for a single $ref, importing both the type
|
|
7
7
|
* and the validator function from the ref's generated file.
|
|
@@ -43,6 +43,10 @@ const collectDirectRefs = (schema) => {
|
|
|
43
43
|
if (hasAdditionalProperties(prop) && hasRef(prop.additionalProperties)) {
|
|
44
44
|
refs.push(prop.additionalProperties.$ref);
|
|
45
45
|
}
|
|
46
|
+
// Inline nested objects are validated recursively by the generator, so any
|
|
47
|
+
// $refs anywhere inside them must become imports as well.
|
|
48
|
+
if (hasProperties(prop))
|
|
49
|
+
refs.push(...collectDirectRefs(prop));
|
|
46
50
|
}
|
|
47
51
|
if (hasItems(schema) && hasRef(schema.items)) {
|
|
48
52
|
refs.push(schema.items.$ref);
|
|
@@ -33,6 +33,12 @@ export const generateValidatorFile = (schema, typeName, options) => {
|
|
|
33
33
|
const typeDefinition = generateTypeDefinition(schema, typeName, { typeSuffix });
|
|
34
34
|
const validatorFunction = generateValidatorFunction(schema, typeName, typeSuffix);
|
|
35
35
|
let result = `import type { ValidationResult, ValidationError } from './validation-result'\n`;
|
|
36
|
+
// `const` checks on object/array values call the runtime `valuesEqual` helper.
|
|
37
|
+
// Only import it when the generated body actually uses it, so files without a
|
|
38
|
+
// structural `const` do not carry an unused import.
|
|
39
|
+
if (validatorFunction.includes('valuesEqual(')) {
|
|
40
|
+
result += `import { valuesEqual } from './validation-result'\n`;
|
|
41
|
+
}
|
|
36
42
|
for (const imp of refImports) {
|
|
37
43
|
result += imp + '\n';
|
|
38
44
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
|
|
1
2
|
import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension';
|
|
2
3
|
import { refToName } from '@amritk/helpers/ref-to-name';
|
|
3
|
-
import { hasAdditionalProperties, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaximum, hasMaxLength, hasMinimum, hasMinLength, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasRef, hasRequired, hasType, isObjectSchema, isSchemaObject, } from '@amritk/helpers/schema-guards';
|
|
4
|
+
import { hasAdditionalProperties, hasConst, hasDependentRequired, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaximum, hasMaxLength, hasMinimum, hasMinLength, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasPropertyNames, hasRef, hasRequired, hasStrictExclusiveMaximum, hasStrictExclusiveMinimum, hasType, isObjectSchema, isSchemaObject, } from '@amritk/helpers/schema-guards';
|
|
4
5
|
/**
|
|
5
6
|
* Derives the validator function name from a type name.
|
|
6
7
|
* e.g. "InfoObject" → "validateInfoObject"
|
|
@@ -14,6 +15,19 @@ const typeofString = (type) => {
|
|
|
14
15
|
return 'number';
|
|
15
16
|
return type;
|
|
16
17
|
};
|
|
18
|
+
/**
|
|
19
|
+
* Generates the inline condition that is TRUE when `accessor` does NOT equal the
|
|
20
|
+
* `const` value. Primitives compare with `!==`; objects/arrays compare with the
|
|
21
|
+
* runtime `valuesEqual` helper so a reordered-but-equal value still matches (the
|
|
22
|
+
* interpreter uses order-independent deep equality, and `JSON.stringify` would
|
|
23
|
+
* disagree because it is key-order sensitive).
|
|
24
|
+
*/
|
|
25
|
+
const constMismatchCondition = (accessor, value) => {
|
|
26
|
+
if (value === null || typeof value !== 'object') {
|
|
27
|
+
return `${accessor} !== ${JSON.stringify(value)}`;
|
|
28
|
+
}
|
|
29
|
+
return `!valuesEqual(${accessor}, ${JSON.stringify(value)})`;
|
|
30
|
+
};
|
|
17
31
|
/**
|
|
18
32
|
* Generates the inline condition that is TRUE when a value is the wrong type.
|
|
19
33
|
*/
|
|
@@ -34,23 +48,57 @@ const wrongTypeCondition = (accessor, type) => {
|
|
|
34
48
|
return '';
|
|
35
49
|
}
|
|
36
50
|
};
|
|
51
|
+
const createRootContext = () => ({ objVar: 'obj', pathPrefix: '${_path}', depth: 0, hoisted: [] });
|
|
52
|
+
/**
|
|
53
|
+
* Generates the unknown-key sweep for `additionalProperties: false`, mirroring
|
|
54
|
+
* the interpreter's behaviour (same error message, one error per extra key).
|
|
55
|
+
* The known-keys Set is hoisted to module scope and the sweep uses `for...in`
|
|
56
|
+
* — the same allocation-free shape Ajv compiles to — so the hot path costs one
|
|
57
|
+
* Set lookup per key. Schemas that combine it with `patternProperties` are
|
|
58
|
+
* skipped: the generator does not evaluate key patterns yet, so rejecting
|
|
59
|
+
* every undeclared key would wrongly fail keys the patterns allow.
|
|
60
|
+
*/
|
|
61
|
+
const generateStrictKeyChecks = (schema, ctx) => {
|
|
62
|
+
if (!isSchemaObject(schema))
|
|
63
|
+
return [];
|
|
64
|
+
if (!hasAdditionalProperties(schema) || schema.additionalProperties !== false)
|
|
65
|
+
return [];
|
|
66
|
+
if ('patternProperties' in schema)
|
|
67
|
+
return [];
|
|
68
|
+
const known = Object.keys(hasProperties(schema) ? schema.properties : {});
|
|
69
|
+
const setName = `_knownKeys${ctx.hoisted.length}`;
|
|
70
|
+
ctx.hoisted.push(`const ${setName} = new Set(${JSON.stringify(known)})`);
|
|
71
|
+
const d = ctx.depth;
|
|
72
|
+
return [
|
|
73
|
+
` for (const _key${d} in ${ctx.objVar}) {`,
|
|
74
|
+
` if (!${setName}.has(_key${d})) {`,
|
|
75
|
+
` errors.push({ message: 'must NOT have additional properties', path: \`${ctx.pathPrefix}/\${_key${d}}\` })`,
|
|
76
|
+
` }`,
|
|
77
|
+
` }`,
|
|
78
|
+
];
|
|
79
|
+
};
|
|
37
80
|
/**
|
|
38
81
|
* Generates validation lines for a single property in an object schema.
|
|
39
|
-
* Handles $ref delegation, enum checks, type checks,
|
|
82
|
+
* Handles $ref delegation, enum checks, type checks, string/number constraints,
|
|
83
|
+
* and recursion into inline nested objects.
|
|
40
84
|
*/
|
|
41
|
-
const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
|
|
85
|
+
const generatePropertyChecks = (key, propSchema, isRequired, suffix, ctx) => {
|
|
42
86
|
if (!isSchemaObject(propSchema))
|
|
43
87
|
return [];
|
|
44
|
-
const raw =
|
|
45
|
-
const path =
|
|
88
|
+
const raw = `${ctx.objVar}[${JSON.stringify(key)}]`;
|
|
89
|
+
const path = `\`${ctx.pathPrefix}/${key}\``;
|
|
90
|
+
// Missing-property errors report at the parent object's path. At the root
|
|
91
|
+
// that is the `_path` parameter itself; inside nested objects it is the
|
|
92
|
+
// parent's accumulated static path.
|
|
93
|
+
const parentPath = ctx.depth === 0 ? '_path' : `\`${ctx.pathPrefix}\``;
|
|
46
94
|
const lines = [];
|
|
47
95
|
// $ref — delegate to the imported validator
|
|
48
96
|
if (hasRef(propSchema)) {
|
|
49
97
|
const ref = propSchema.$ref;
|
|
50
98
|
const vName = validatorName(refToName(ref, suffix));
|
|
51
99
|
if (isRequired) {
|
|
52
|
-
lines.push(` if (!(${JSON.stringify(key)} in
|
|
53
|
-
lines.push(` errors.push({ message: "must have required property '${key}'", path:
|
|
100
|
+
lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
|
|
101
|
+
lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
|
|
54
102
|
lines.push(` } else {`);
|
|
55
103
|
lines.push(` const _r = ${vName}(${raw}, ${path})`);
|
|
56
104
|
lines.push(` if (_r !== true) errors.push(..._r.errors)`);
|
|
@@ -68,8 +116,8 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
|
|
|
68
116
|
const instanceOf = getMjstInstanceOf(propSchema);
|
|
69
117
|
if (instanceOf) {
|
|
70
118
|
if (isRequired) {
|
|
71
|
-
lines.push(` if (!(${JSON.stringify(key)} in
|
|
72
|
-
lines.push(` errors.push({ message: "must have required property '${key}'", path:
|
|
119
|
+
lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
|
|
120
|
+
lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
|
|
73
121
|
lines.push(` } else if (!(${raw} instanceof ${instanceOf})) {`);
|
|
74
122
|
lines.push(` errors.push({ message: 'must be ${instanceOf}', path: ${path} })`);
|
|
75
123
|
lines.push(` }`);
|
|
@@ -85,8 +133,8 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
|
|
|
85
133
|
const primitive = getMjstPrimitive(propSchema);
|
|
86
134
|
if (primitive) {
|
|
87
135
|
if (isRequired) {
|
|
88
|
-
lines.push(` if (!(${JSON.stringify(key)} in
|
|
89
|
-
lines.push(` errors.push({ message: "must have required property '${key}'", path:
|
|
136
|
+
lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
|
|
137
|
+
lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
|
|
90
138
|
lines.push(` } else if (typeof ${raw} !== "${primitive}") {`);
|
|
91
139
|
lines.push(` errors.push({ message: 'must be ${primitive}', path: ${path} })`);
|
|
92
140
|
lines.push(` }`);
|
|
@@ -98,13 +146,31 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
|
|
|
98
146
|
}
|
|
99
147
|
return lines;
|
|
100
148
|
}
|
|
149
|
+
// const — value must equal the fixed value exactly
|
|
150
|
+
if (hasConst(propSchema)) {
|
|
151
|
+
const mismatch = constMismatchCondition(raw, propSchema.const);
|
|
152
|
+
const msg = JSON.stringify(`must be ${JSON.stringify(propSchema.const)}`);
|
|
153
|
+
if (isRequired) {
|
|
154
|
+
lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
|
|
155
|
+
lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
|
|
156
|
+
lines.push(` } else if (${mismatch}) {`);
|
|
157
|
+
lines.push(` errors.push({ message: ${msg}, path: ${path} })`);
|
|
158
|
+
lines.push(` }`);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
lines.push(` if (${raw} !== undefined && ${mismatch}) {`);
|
|
162
|
+
lines.push(` errors.push({ message: ${msg}, path: ${path} })`);
|
|
163
|
+
lines.push(` }`);
|
|
164
|
+
}
|
|
165
|
+
return lines;
|
|
166
|
+
}
|
|
101
167
|
// enum
|
|
102
168
|
if (hasEnum(propSchema)) {
|
|
103
169
|
const allowed = JSON.stringify(propSchema.enum);
|
|
104
170
|
const label = propSchema.enum.map((v) => JSON.stringify(v)).join(', ');
|
|
105
171
|
if (isRequired) {
|
|
106
|
-
lines.push(` if (!(${JSON.stringify(key)} in
|
|
107
|
-
lines.push(` errors.push({ message: "must have required property '${key}'", path:
|
|
172
|
+
lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
|
|
173
|
+
lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
|
|
108
174
|
lines.push(` } else if (!(${allowed} as unknown[]).includes(${raw})) {`);
|
|
109
175
|
lines.push(` errors.push({ message: \`must be one of: ${label}\`, path: ${path} })`);
|
|
110
176
|
lines.push(` }`);
|
|
@@ -122,8 +188,8 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
|
|
|
122
188
|
const wrongType = wrongTypeCondition(raw, t);
|
|
123
189
|
const typLabel = typeofString(t);
|
|
124
190
|
if (isRequired) {
|
|
125
|
-
lines.push(` if (!(${JSON.stringify(key)} in
|
|
126
|
-
lines.push(` errors.push({ message: "must have required property '${key}'", path:
|
|
191
|
+
lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
|
|
192
|
+
lines.push(` errors.push({ message: "must have required property '${key}'", path: ${parentPath} })`);
|
|
127
193
|
if (wrongType) {
|
|
128
194
|
lines.push(` } else if (${wrongType}) {`);
|
|
129
195
|
lines.push(` errors.push({ message: 'must be ${typLabel}', path: ${path} })`);
|
|
@@ -138,8 +204,10 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
|
|
|
138
204
|
// String constraints
|
|
139
205
|
if (t === 'string') {
|
|
140
206
|
if (hasPattern(propSchema)) {
|
|
141
|
-
|
|
142
|
-
|
|
207
|
+
const re = escapeRegexPattern(propSchema.pattern);
|
|
208
|
+
const msg = JSON.stringify(`must match pattern ${propSchema.pattern}`);
|
|
209
|
+
lines.push(` if (typeof ${raw} === 'string' && !/${re}/.test(${raw})) {`);
|
|
210
|
+
lines.push(` errors.push({ message: ${msg}, path: ${path} })`);
|
|
143
211
|
lines.push(` }`);
|
|
144
212
|
}
|
|
145
213
|
if (hasMinLength(propSchema)) {
|
|
@@ -156,13 +224,20 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
|
|
|
156
224
|
// Number constraints
|
|
157
225
|
if (t === 'number' || t === 'integer') {
|
|
158
226
|
if (hasMinimum(propSchema)) {
|
|
159
|
-
|
|
160
|
-
|
|
227
|
+
// Draft-04 `exclusiveMinimum: true` makes the paired `minimum` strict.
|
|
228
|
+
const strict = hasStrictExclusiveMinimum(propSchema);
|
|
229
|
+
const op = strict ? '<=' : '<';
|
|
230
|
+
const rel = strict ? '>' : '>=';
|
|
231
|
+
lines.push(` if (typeof ${raw} === 'number' && ${raw} ${op} ${propSchema.minimum}) {`);
|
|
232
|
+
lines.push(` errors.push({ message: 'must be ${rel} ${propSchema.minimum}', path: ${path} })`);
|
|
161
233
|
lines.push(` }`);
|
|
162
234
|
}
|
|
163
235
|
if (hasMaximum(propSchema)) {
|
|
164
|
-
|
|
165
|
-
|
|
236
|
+
const strict = hasStrictExclusiveMaximum(propSchema);
|
|
237
|
+
const op = strict ? '>=' : '>';
|
|
238
|
+
const rel = strict ? '<' : '<=';
|
|
239
|
+
lines.push(` if (typeof ${raw} === 'number' && ${raw} ${op} ${propSchema.maximum}) {`);
|
|
240
|
+
lines.push(` errors.push({ message: 'must be ${rel} ${propSchema.maximum}', path: ${path} })`);
|
|
166
241
|
lines.push(` }`);
|
|
167
242
|
}
|
|
168
243
|
if (hasExclusiveMinimum(propSchema)) {
|
|
@@ -188,7 +263,7 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
|
|
|
188
263
|
const vName = validatorName(refToName(itemSchema.$ref, suffix));
|
|
189
264
|
lines.push(` if (Array.isArray(${raw})) {`);
|
|
190
265
|
lines.push(` for (let _i = 0; _i < ${raw}.length; _i++) {`);
|
|
191
|
-
lines.push(` const _ir = ${vName}(${raw}[_i], \`${path.slice(1, -1)}
|
|
266
|
+
lines.push(` const _ir = ${vName}(${raw}[_i], \`${path.slice(1, -1)}/\${_i}\`)`);
|
|
192
267
|
lines.push(` if (_ir !== true) errors.push(..._ir.errors)`);
|
|
193
268
|
lines.push(` }`);
|
|
194
269
|
lines.push(` }`);
|
|
@@ -201,15 +276,100 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix) => {
|
|
|
201
276
|
lines.push(` if (Array.isArray(${raw})) {`);
|
|
202
277
|
lines.push(` for (let _i = 0; _i < ${raw}.length; _i++) {`);
|
|
203
278
|
lines.push(` const _item = ${raw}[_i]`);
|
|
204
|
-
lines.push(` if (${itemWrong}) errors.push({ message: 'items must be ${itemLabel}', path: \`${path.slice(1, -1)}
|
|
279
|
+
lines.push(` if (${itemWrong}) errors.push({ message: 'items must be ${itemLabel}', path: \`${path.slice(1, -1)}/\${_i}\` })`);
|
|
205
280
|
lines.push(` }`);
|
|
206
281
|
lines.push(` }`);
|
|
207
282
|
}
|
|
208
283
|
}
|
|
209
284
|
}
|
|
285
|
+
// Inline nested object — recurse so the nested fields are actually
|
|
286
|
+
// validated. Without this only the "must be object" shape check above
|
|
287
|
+
// runs and everything inside the nested object silently passes.
|
|
288
|
+
if (t === 'object') {
|
|
289
|
+
lines.push(...generateInlineObjectChecks(key, propSchema, raw, suffix, ctx));
|
|
290
|
+
}
|
|
210
291
|
}
|
|
211
292
|
return lines;
|
|
212
293
|
};
|
|
294
|
+
/**
|
|
295
|
+
* Generates the recursive checks for an inline nested object property, i.e. an
|
|
296
|
+
* object schema written directly under `properties` rather than referenced via
|
|
297
|
+
* `$ref` (those delegate to the referenced validator instead). The value is
|
|
298
|
+
* narrowed into its own block-scoped variable and each nested property runs
|
|
299
|
+
* through the same per-property generator, so nesting works to any depth.
|
|
300
|
+
*/
|
|
301
|
+
const generateInlineObjectChecks = (key, propSchema, raw, suffix, ctx) => {
|
|
302
|
+
if (!isSchemaObject(propSchema))
|
|
303
|
+
return [];
|
|
304
|
+
const child = {
|
|
305
|
+
objVar: `_obj${ctx.depth + 1}`,
|
|
306
|
+
pathPrefix: `${ctx.pathPrefix}/${key}`,
|
|
307
|
+
depth: ctx.depth + 1,
|
|
308
|
+
hoisted: ctx.hoisted,
|
|
309
|
+
};
|
|
310
|
+
const required = new Set(hasRequired(propSchema) ? propSchema.required : []);
|
|
311
|
+
const properties = hasProperties(propSchema) ? propSchema.properties : {};
|
|
312
|
+
const innerLines = [];
|
|
313
|
+
for (const [childKey, childSchema] of Object.entries(properties)) {
|
|
314
|
+
innerLines.push(...generatePropertyChecks(childKey, childSchema, required.has(childKey), suffix, child));
|
|
315
|
+
}
|
|
316
|
+
innerLines.push(...generateStrictKeyChecks(propSchema, child));
|
|
317
|
+
if (innerLines.length === 0)
|
|
318
|
+
return [];
|
|
319
|
+
// The shape check for the property itself already ran (or the property is
|
|
320
|
+
// optional), so re-guard here instead of assuming the value is an object.
|
|
321
|
+
return [
|
|
322
|
+
` if (typeof ${raw} === 'object' && ${raw} !== null && !Array.isArray(${raw})) {`,
|
|
323
|
+
` const ${child.objVar} = ${raw} as Record<string, unknown>`,
|
|
324
|
+
...innerLines.map((line) => ` ${line}`),
|
|
325
|
+
` }`,
|
|
326
|
+
];
|
|
327
|
+
};
|
|
328
|
+
/**
|
|
329
|
+
* Generates the `propertyNames` loop: every object key is a string, so we apply
|
|
330
|
+
* the string-relevant constraints of the subschema (or delegate to a `$ref`'s
|
|
331
|
+
* validator). This keeps the generator in step with the interpreter, which runs
|
|
332
|
+
* the whole subschema against each key — not just the `pattern` form.
|
|
333
|
+
*/
|
|
334
|
+
const generatePropertyNameChecks = (nameSchema, suffix) => {
|
|
335
|
+
if (!isSchemaObject(nameSchema))
|
|
336
|
+
return [];
|
|
337
|
+
const at = '`${_path}/${_name}`';
|
|
338
|
+
const checks = [];
|
|
339
|
+
if (hasRef(nameSchema)) {
|
|
340
|
+
const vName = validatorName(refToName(nameSchema.$ref, suffix));
|
|
341
|
+
checks.push(` const _nr = ${vName}(_name, ${at})`);
|
|
342
|
+
checks.push(` if (_nr !== true) errors.push(..._nr.errors)`);
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
if (hasPattern(nameSchema)) {
|
|
346
|
+
const re = escapeRegexPattern(nameSchema.pattern);
|
|
347
|
+
const msg = JSON.stringify(`property name must match pattern ${nameSchema.pattern}`);
|
|
348
|
+
checks.push(` if (!/${re}/.test(_name)) errors.push({ message: ${msg}, path: ${at} })`);
|
|
349
|
+
}
|
|
350
|
+
if (hasMinLength(nameSchema)) {
|
|
351
|
+
const msg = JSON.stringify(`property name must have at least ${nameSchema.minLength} characters`);
|
|
352
|
+
checks.push(` if (_name.length < ${nameSchema.minLength}) errors.push({ message: ${msg}, path: ${at} })`);
|
|
353
|
+
}
|
|
354
|
+
if (hasMaxLength(nameSchema)) {
|
|
355
|
+
const msg = JSON.stringify(`property name must have at most ${nameSchema.maxLength} characters`);
|
|
356
|
+
checks.push(` if (_name.length > ${nameSchema.maxLength}) errors.push({ message: ${msg}, path: ${at} })`);
|
|
357
|
+
}
|
|
358
|
+
if (hasEnum(nameSchema)) {
|
|
359
|
+
const allowed = JSON.stringify(nameSchema.enum);
|
|
360
|
+
const label = nameSchema.enum.map((v) => JSON.stringify(v)).join(', ');
|
|
361
|
+
const msg = JSON.stringify(`property name must be one of: ${label}`);
|
|
362
|
+
checks.push(` if (!(${allowed} as unknown[]).includes(_name)) errors.push({ message: ${msg}, path: ${at} })`);
|
|
363
|
+
}
|
|
364
|
+
if (hasConst(nameSchema)) {
|
|
365
|
+
const msg = JSON.stringify(`property name must be ${JSON.stringify(nameSchema.const)}`);
|
|
366
|
+
checks.push(` if (_name !== ${JSON.stringify(nameSchema.const)}) errors.push({ message: ${msg}, path: ${at} })`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (checks.length === 0)
|
|
370
|
+
return [];
|
|
371
|
+
return [` for (const _name of Object.keys(obj)) {`, ...checks, ` }`];
|
|
372
|
+
};
|
|
213
373
|
/**
|
|
214
374
|
* Generates a validator function body for an object schema, checking each
|
|
215
375
|
* property's presence and type and collecting all errors.
|
|
@@ -218,9 +378,10 @@ const generateObjectValidator = (schema, typeName, suffix) => {
|
|
|
218
378
|
const vName = validatorName(typeName);
|
|
219
379
|
const required = new Set(hasRequired(schema) ? schema.required : []);
|
|
220
380
|
const properties = hasProperties(schema) ? schema.properties : {};
|
|
381
|
+
const ctx = createRootContext();
|
|
221
382
|
const propertyLines = [];
|
|
222
383
|
for (const [key, propSchema] of Object.entries(properties)) {
|
|
223
|
-
const checks = generatePropertyChecks(key, propSchema, required.has(key), suffix);
|
|
384
|
+
const checks = generatePropertyChecks(key, propSchema, required.has(key), suffix, ctx);
|
|
224
385
|
if (checks.length > 0) {
|
|
225
386
|
propertyLines.push(...checks);
|
|
226
387
|
}
|
|
@@ -236,9 +397,32 @@ const generateObjectValidator = (schema, typeName, suffix) => {
|
|
|
236
397
|
propertyLines.push(` if (_r !== true) errors.push(..._r.errors)`);
|
|
237
398
|
propertyLines.push(` }`);
|
|
238
399
|
}
|
|
400
|
+
// additionalProperties: false rejects every key not declared in properties
|
|
401
|
+
propertyLines.push(...generateStrictKeyChecks(schema, ctx));
|
|
402
|
+
// dependentRequired — when a trigger property is present, its dependencies must be too.
|
|
403
|
+
if (hasDependentRequired(schema)) {
|
|
404
|
+
for (const [trigger, deps] of Object.entries(schema.dependentRequired)) {
|
|
405
|
+
if (!Array.isArray(deps))
|
|
406
|
+
continue;
|
|
407
|
+
for (const dep of deps) {
|
|
408
|
+
const msg = JSON.stringify(`must have property '${dep}' when '${trigger}' is present`);
|
|
409
|
+
propertyLines.push(` if (${JSON.stringify(trigger)} in obj && !(${JSON.stringify(dep)} in obj)) {`);
|
|
410
|
+
propertyLines.push(` errors.push({ message: ${msg}, path: _path })`);
|
|
411
|
+
propertyLines.push(` }`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
// propertyNames — every key (always a string) must satisfy the subschema. This
|
|
416
|
+
// mirrors the interpreter, which runs the full subschema against each key.
|
|
417
|
+
if (hasPropertyNames(schema) && isSchemaObject(schema.propertyNames)) {
|
|
418
|
+
propertyLines.push(...generatePropertyNameChecks(schema.propertyNames, suffix));
|
|
419
|
+
}
|
|
239
420
|
const body = propertyLines.length > 0 ? '\n' + propertyLines.join('\n') + '\n' : '';
|
|
421
|
+
// Hoisted statements (e.g. known-keys Sets) come first so every call of the
|
|
422
|
+
// validator reuses them instead of rebuilding them.
|
|
423
|
+
const hoistedBlock = ctx.hoisted.length > 0 ? `${ctx.hoisted.join('\n')}\n\n` : '';
|
|
240
424
|
return [
|
|
241
|
-
|
|
425
|
+
`${hoistedBlock}export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
|
|
242
426
|
` if (typeof input !== 'object' || input === null || Array.isArray(input)) {`,
|
|
243
427
|
` return { valid: false, errors: [{ message: 'must be object', path: _path }] }`,
|
|
244
428
|
` }`,
|
|
@@ -291,6 +475,19 @@ const generateScalarValidator = (schema, typeName, suffix) => {
|
|
|
291
475
|
`}`,
|
|
292
476
|
].join('\n');
|
|
293
477
|
}
|
|
478
|
+
// Top-level const
|
|
479
|
+
if (hasConst(schema)) {
|
|
480
|
+
const mismatch = constMismatchCondition('input', schema.const);
|
|
481
|
+
const msg = JSON.stringify(`must be ${JSON.stringify(schema.const)}`);
|
|
482
|
+
return [
|
|
483
|
+
`export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
|
|
484
|
+
` if (${mismatch}) {`,
|
|
485
|
+
` return { valid: false, errors: [{ message: ${msg}, path: _path }] }`,
|
|
486
|
+
` }`,
|
|
487
|
+
` return true`,
|
|
488
|
+
`}`,
|
|
489
|
+
].join('\n');
|
|
490
|
+
}
|
|
294
491
|
// Top-level enum
|
|
295
492
|
if (hasEnum(schema)) {
|
|
296
493
|
const allowed = JSON.stringify(schema.enum);
|
|
@@ -330,8 +527,10 @@ const generateScalarValidator = (schema, typeName, suffix) => {
|
|
|
330
527
|
const constraintLines = [];
|
|
331
528
|
if (t === 'string') {
|
|
332
529
|
if (hasPattern(schema)) {
|
|
333
|
-
|
|
334
|
-
|
|
530
|
+
const re = escapeRegexPattern(schema.pattern);
|
|
531
|
+
const msg = JSON.stringify(`must match pattern ${schema.pattern}`);
|
|
532
|
+
constraintLines.push(` if (typeof input === 'string' && !/${re}/.test(input)) {`);
|
|
533
|
+
constraintLines.push(` errors.push({ message: ${msg}, path: _path })`);
|
|
335
534
|
constraintLines.push(` }`);
|
|
336
535
|
}
|
|
337
536
|
if (hasMinLength(schema)) {
|
package/dist/index.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amritk/generate-validators",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Generate TypeScript validation functions from JSON Schemas.",
|
|
5
5
|
"module": "./dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -24,8 +24,7 @@
|
|
|
24
24
|
"url": "https://github.com/amritk/mjst/issues"
|
|
25
25
|
},
|
|
26
26
|
"files": [
|
|
27
|
-
"dist"
|
|
28
|
-
"src"
|
|
27
|
+
"dist"
|
|
29
28
|
],
|
|
30
29
|
"publishConfig": {
|
|
31
30
|
"access": "public"
|
|
@@ -33,23 +32,27 @@
|
|
|
33
32
|
"scripts": {
|
|
34
33
|
"build": "tsgo -p tsconfig.build.json && tsc-alias -p tsconfig.build.json -f",
|
|
35
34
|
"types:check": "tsgo -p . --noEmit",
|
|
36
|
-
"test": "NODE_ENV=production vitest run --root ../.. generate-validators"
|
|
35
|
+
"test": "NODE_ENV=production vitest run --root ../.. generate-validators",
|
|
36
|
+
"bench": "bun run ./bench/run.ts"
|
|
37
37
|
},
|
|
38
38
|
"imports": {
|
|
39
39
|
"#generators/*": "./src/generators/*.ts"
|
|
40
40
|
},
|
|
41
41
|
"exports": {
|
|
42
42
|
".": {
|
|
43
|
-
"development": "./src/index.ts",
|
|
44
43
|
"default": "./dist/index.js",
|
|
45
44
|
"types": "./dist/index.d.ts"
|
|
46
45
|
}
|
|
47
46
|
},
|
|
48
47
|
"dependencies": {
|
|
49
48
|
"json-schema-typed": "^8.0.1",
|
|
50
|
-
"@amritk/helpers": "0.
|
|
49
|
+
"@amritk/helpers": "0.9.0"
|
|
51
50
|
},
|
|
52
51
|
"devDependencies": {
|
|
53
|
-
"@scalar/openapi-parser": "^0.26.1"
|
|
52
|
+
"@scalar/openapi-parser": "^0.26.1",
|
|
53
|
+
"@sinclair/typebox": "^0.34.49",
|
|
54
|
+
"ajv": "^8.17.1",
|
|
55
|
+
"ajv-formats": "^3.0.1",
|
|
56
|
+
"zod": "^4.4.3"
|
|
54
57
|
}
|
|
55
58
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"build-schema.d.ts","sourceRoot":"","sources":["../../src/generators/build-schema.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AAIjE;;GAEG;AACH,MAAM,MAAM,aAAa,GAAG;IAC1B,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,MAAM,CAAA;CAChB,CAAA;AAmBD;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,oBAAoB,eACnB,UAAU,gBACR,MAAM,0BAEnB,OAAO,CAAC,aAAa,EAAE,CAuBzB,CAAA"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"collect-validator-imports.d.ts","sourceRoot":"","sources":["../../src/generators/collect-validator-imports.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AAEjE;;GAEG;AACH,KAAK,8BAA8B,GAAG;IACpC;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAA;IACrC;;;OAGG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,CAAA;IACzD;;;OAGG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC7B,CAAA;AAoED;;;;;;;;;;GAUG;AACH,eAAO,MAAM,uBAAuB,WAAY,UAAU,YAAY,8BAA8B,KAAG,MAAM,EA6B5G,CAAA"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"generate-files.d.ts","sourceRoot":"","sources":["../../src/generators/generate-files.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AAKjE;;GAEG;AACH,KAAK,4BAA4B,GAAG;IAClC;;;OAGG;IACH,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;IACzB;;OAEG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC7C;;;OAGG;IACH,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC7B,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,qBAAqB,WACxB,UAAU,YACR,MAAM,YACN,4BAA4B,KACrC,MA0BF,CAAA"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"generate-validator-function.d.ts","sourceRoot":"","sources":["../../src/generators/generate-validator-function.ts"],"names":[],"mappings":"AAsBA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iCAAiC,CAAA;AAqajE;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,yBAAyB,WAAY,UAAU,YAAY,MAAM,sBAAgB,MAM7F,CAAA"}
|
package/dist/index.d.ts.map
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAA;AAC9D,OAAO,EAAE,oBAAoB,EAAE,MAAM,2BAA2B,CAAA"}
|