@amritk/generate-validators 0.11.0 → 0.11.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.
|
@@ -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 {
|
|
4
|
+
import { 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.
|
|
@@ -10,7 +10,9 @@ const buildImport = (ref, suffix) => {
|
|
|
10
10
|
const filename = refToFilename(ref);
|
|
11
11
|
const typeName = refToName(ref, suffix);
|
|
12
12
|
const validatorName = `validate${typeName}`;
|
|
13
|
-
|
|
13
|
+
// `.js` extension so the emitted import resolves under Node ESM (not just Bun);
|
|
14
|
+
// `./x.js` → sibling `x.ts` is the standard NodeNext form.
|
|
15
|
+
return `import { type ${typeName}, ${validatorName} } from './${filename}.js'`;
|
|
14
16
|
};
|
|
15
17
|
/**
|
|
16
18
|
* Resolves the canonical filename for a ref, stripping `-or-reference` suffixes
|
|
@@ -21,46 +23,57 @@ const canonicalFilename = (ref) => {
|
|
|
21
23
|
return refToFilename(base);
|
|
22
24
|
};
|
|
23
25
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
+
* Recursively walks a schema and yields every `$ref` the validator emitter can
|
|
27
|
+
* turn into a `validateX(...)` call, in traversal order. The emitter recurses
|
|
28
|
+
* into far more than properties/items/additionalProperties/top-level
|
|
29
|
+
* combinators: it also delegates for `patternProperties`, `propertyNames`,
|
|
30
|
+
* `if`/`then`/`else`, `contains`, `prefixItems`, `dependentSchemas`, `not`, and
|
|
31
|
+
* objects nested inside any combinator branch. A `$ref` reached by *any* of those
|
|
32
|
+
* paths must become an import, or the generated file references an undefined
|
|
33
|
+
* `validateX`. (Mirrors the parsers package's `collect-imports` traversal.)
|
|
26
34
|
*/
|
|
27
|
-
const collectDirectRefs = (
|
|
28
|
-
if (typeof
|
|
29
|
-
return
|
|
30
|
-
|
|
35
|
+
const collectDirectRefs = (value, refs = []) => {
|
|
36
|
+
if (typeof value !== 'object' || value === null)
|
|
37
|
+
return refs;
|
|
38
|
+
if (Array.isArray(value)) {
|
|
39
|
+
for (const item of value)
|
|
40
|
+
collectDirectRefs(item, refs);
|
|
41
|
+
return refs;
|
|
42
|
+
}
|
|
43
|
+
const schema = value;
|
|
44
|
+
// A `$ref` is a leaf: the emitter delegates the whole value to the referenced
|
|
45
|
+
// validator, so record the ref and do not descend past it.
|
|
31
46
|
if (hasRef(schema)) {
|
|
32
47
|
refs.push(schema.$ref);
|
|
33
48
|
return refs;
|
|
34
49
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
50
|
+
// Every keyword whose subschema(s) the emitter recurses into. `properties` and
|
|
51
|
+
// `patternProperties` hold subschemas as object *values*; the combinator/tuple
|
|
52
|
+
// keywords hold them in arrays; the rest are single subschemas. We deliberately
|
|
53
|
+
// do NOT descend into `$defs`/`definitions` — those are split into their own
|
|
54
|
+
// generated files, not inlined by this validator. `collectDirectRefs`
|
|
55
|
+
// self-guards on non-objects, so a keyword that is a boolean or missing is a
|
|
56
|
+
// harmless no-op.
|
|
57
|
+
const subSchemaMaps = ['properties', 'patternProperties'];
|
|
58
|
+
for (const mapKey of subSchemaMaps) {
|
|
59
|
+
const map = schema[mapKey];
|
|
60
|
+
if (typeof map === 'object' && map !== null && !Array.isArray(map)) {
|
|
61
|
+
for (const sub of Object.values(map))
|
|
62
|
+
collectDirectRefs(sub, refs);
|
|
45
63
|
}
|
|
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));
|
|
50
64
|
}
|
|
51
|
-
|
|
52
|
-
|
|
65
|
+
const singleSubSchemas = ['items', 'additionalProperties', 'propertyNames', 'contains', 'if', 'then', 'else', 'not'];
|
|
66
|
+
for (const key of singleSubSchemas) {
|
|
67
|
+
if (key in schema)
|
|
68
|
+
collectDirectRefs(schema[key], refs);
|
|
53
69
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
]) {
|
|
62
|
-
if (hasRef(branch))
|
|
63
|
-
refs.push(branch.$ref);
|
|
70
|
+
const arraySubSchemas = ['oneOf', 'anyOf', 'allOf', 'prefixItems'];
|
|
71
|
+
for (const key of arraySubSchemas) {
|
|
72
|
+
const arr = schema[key];
|
|
73
|
+
if (Array.isArray(arr)) {
|
|
74
|
+
for (const sub of arr)
|
|
75
|
+
collectDirectRefs(sub, refs);
|
|
76
|
+
}
|
|
64
77
|
}
|
|
65
78
|
return refs;
|
|
66
79
|
};
|
|
@@ -35,12 +35,13 @@ export const generateValidatorFile = (schema, typeName, options) => {
|
|
|
35
35
|
const typeDefinition = generateTypeDefinition(schema, typeName, { typeSuffix });
|
|
36
36
|
const validatorFunction = generateValidatorFunction(schema, typeName, typeSuffix);
|
|
37
37
|
const booleanGuard = generateBooleanGuard(schema, typeName, typeSuffix);
|
|
38
|
-
|
|
38
|
+
// `.js` extension so the relative import resolves under Node ESM, not only Bun.
|
|
39
|
+
let result = `import type { ValidationResult, ValidationError } from './validation-result.js'\n`;
|
|
39
40
|
// `const` checks on object/array values call the runtime `valuesEqual` helper.
|
|
40
41
|
// Only import it when the generated body actually uses it, so files without a
|
|
41
42
|
// structural `const` do not carry an unused import.
|
|
42
43
|
if (validatorFunction.includes('valuesEqual(')) {
|
|
43
|
-
result += `import { valuesEqual } from './validation-result'\n`;
|
|
44
|
+
result += `import { valuesEqual } from './validation-result.js'\n`;
|
|
44
45
|
}
|
|
45
46
|
for (const imp of refImports) {
|
|
46
47
|
result += imp + '\n';
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { escapeRegexPattern } from '@amritk/helpers/escape-regex-pattern';
|
|
2
2
|
import { getMjstInstanceOf, getMjstPrimitive } from '@amritk/helpers/mjst-extension';
|
|
3
|
+
import { multipleOfFailExpr, multipleOfPassExpr } from '@amritk/helpers/multiple-of-check';
|
|
3
4
|
import { refToName } from '@amritk/helpers/ref-to-name';
|
|
4
5
|
import { safeAccessor } from '@amritk/helpers/safe-accessor';
|
|
5
6
|
import { hasAdditionalProperties, hasAllOf, hasAnyOf, hasConst, hasDependentRequired, hasEnum, hasExclusiveMaximum, hasExclusiveMinimum, hasItems, hasMaxItems, hasMaximum, hasMaxLength, hasMinItems, hasMinimum, hasMinLength, hasMultipleOf, hasOneOf, hasPattern, hasProperties, hasPropertyNames, hasRef, hasRequired, hasStrictExclusiveMaximum, hasStrictExclusiveMinimum, hasType, hasUniqueItems, isObjectSchema, isSchemaObject, } from '@amritk/helpers/schema-guards';
|
|
@@ -53,6 +54,19 @@ const wrongTypeCondition = (accessor, type) => {
|
|
|
53
54
|
return '';
|
|
54
55
|
}
|
|
55
56
|
};
|
|
57
|
+
/**
|
|
58
|
+
* Returns the list of type names when a schema's `type` is an array (the JSON
|
|
59
|
+
* Schema multi-type / nullable idiom, e.g. `["string","null"]`), else `null`.
|
|
60
|
+
* `hasType` only recognises a *string* `type`, so without special handling a
|
|
61
|
+
* multi-type schema slips through every branch and emits NO check — not even a
|
|
62
|
+
* required-presence check. A multi-type is validated as the *disjunction* of its
|
|
63
|
+
* per-type checks (the value must match at least one).
|
|
64
|
+
*/
|
|
65
|
+
const getTypeArray = (schema) => {
|
|
66
|
+
if (!isSchemaObject(schema) || !('type' in schema) || !Array.isArray(schema.type))
|
|
67
|
+
return null;
|
|
68
|
+
return schema.type;
|
|
69
|
+
};
|
|
56
70
|
const createRootContext = () => ({ objVar: 'obj', pathPrefix: '${_path}', depth: 0, hoisted: [] });
|
|
57
71
|
/**
|
|
58
72
|
* Returns the `patternProperties` regex sources, or an empty array when the
|
|
@@ -147,7 +161,7 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix, ctx) => {
|
|
|
147
161
|
const vName = validatorName(refToName(ref, suffix));
|
|
148
162
|
if (isRequired) {
|
|
149
163
|
lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
|
|
150
|
-
lines.push(` errors.push({ message:
|
|
164
|
+
lines.push(` errors.push({ message: ${JSON.stringify(`must have required property '${key}'`)}, path: ${parentPath} })`);
|
|
151
165
|
lines.push(` } else {`);
|
|
152
166
|
lines.push(` const _r = ${vName}(${raw}, ${path})`);
|
|
153
167
|
lines.push(` if (_r !== true) errors.push(..._r.errors)`);
|
|
@@ -166,7 +180,7 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix, ctx) => {
|
|
|
166
180
|
if (instanceOf) {
|
|
167
181
|
if (isRequired) {
|
|
168
182
|
lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
|
|
169
|
-
lines.push(` errors.push({ message:
|
|
183
|
+
lines.push(` errors.push({ message: ${JSON.stringify(`must have required property '${key}'`)}, path: ${parentPath} })`);
|
|
170
184
|
lines.push(` } else if (!(${raw} instanceof ${instanceOf})) {`);
|
|
171
185
|
lines.push(` errors.push({ message: 'must be ${instanceOf}', path: ${path} })`);
|
|
172
186
|
lines.push(` }`);
|
|
@@ -183,7 +197,7 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix, ctx) => {
|
|
|
183
197
|
if (primitive) {
|
|
184
198
|
if (isRequired) {
|
|
185
199
|
lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
|
|
186
|
-
lines.push(` errors.push({ message:
|
|
200
|
+
lines.push(` errors.push({ message: ${JSON.stringify(`must have required property '${key}'`)}, path: ${parentPath} })`);
|
|
187
201
|
lines.push(` } else if (typeof ${raw} !== "${primitive}") {`);
|
|
188
202
|
lines.push(` errors.push({ message: 'must be ${primitive}', path: ${path} })`);
|
|
189
203
|
lines.push(` }`);
|
|
@@ -201,7 +215,7 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix, ctx) => {
|
|
|
201
215
|
const msg = JSON.stringify(`must be ${JSON.stringify(propSchema.const)}`);
|
|
202
216
|
if (isRequired) {
|
|
203
217
|
lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
|
|
204
|
-
lines.push(` errors.push({ message:
|
|
218
|
+
lines.push(` errors.push({ message: ${JSON.stringify(`must have required property '${key}'`)}, path: ${parentPath} })`);
|
|
205
219
|
lines.push(` } else if (${mismatch}) {`);
|
|
206
220
|
lines.push(` errors.push({ message: ${msg}, path: ${path} })`);
|
|
207
221
|
lines.push(` }`);
|
|
@@ -219,16 +233,49 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix, ctx) => {
|
|
|
219
233
|
const label = propSchema.enum.map((v) => JSON.stringify(v)).join(', ');
|
|
220
234
|
if (isRequired) {
|
|
221
235
|
lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
|
|
222
|
-
lines.push(` errors.push({ message:
|
|
236
|
+
lines.push(` errors.push({ message: ${JSON.stringify(`must have required property '${key}'`)}, path: ${parentPath} })`);
|
|
223
237
|
lines.push(` } else if (!(${allowed} as unknown[]).includes(${raw})) {`);
|
|
224
|
-
lines.push(` errors.push({ message:
|
|
238
|
+
lines.push(` errors.push({ message: ${JSON.stringify(`must be one of: ${label}`)}, path: ${path} })`);
|
|
225
239
|
lines.push(` }`);
|
|
226
240
|
}
|
|
227
241
|
else {
|
|
228
242
|
lines.push(` if (${raw} !== undefined && !(${allowed} as unknown[]).includes(${raw})) {`);
|
|
229
|
-
lines.push(` errors.push({ message:
|
|
243
|
+
lines.push(` errors.push({ message: ${JSON.stringify(`must be one of: ${label}`)}, path: ${path} })`);
|
|
244
|
+
lines.push(` }`);
|
|
245
|
+
}
|
|
246
|
+
return lines;
|
|
247
|
+
}
|
|
248
|
+
// Multi-type / nullable property (array `type`, e.g. `["string","null"]`).
|
|
249
|
+
// `hasType` is false for an array `type`, so without this the property emits no
|
|
250
|
+
// check at all. The value is valid when it matches ANY listed type, i.e. an
|
|
251
|
+
// error is reported only when it is the wrong type for EVERY listed type; the
|
|
252
|
+
// required-presence check is still emitted so a missing required prop fails.
|
|
253
|
+
const typeArray = getTypeArray(propSchema);
|
|
254
|
+
if (typeArray) {
|
|
255
|
+
const allWrong = typeArray
|
|
256
|
+
.map((t) => wrongTypeCondition(raw, t))
|
|
257
|
+
.filter((c) => c !== '')
|
|
258
|
+
.map((c) => `(${c})`)
|
|
259
|
+
.join(' && ');
|
|
260
|
+
const label = typeArray.map((t) => typeofString(t)).join(' or ');
|
|
261
|
+
if (isRequired) {
|
|
262
|
+
lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
|
|
263
|
+
lines.push(` errors.push({ message: ${JSON.stringify(`must have required property '${key}'`)}, path: ${parentPath} })`);
|
|
264
|
+
if (allWrong) {
|
|
265
|
+
lines.push(` } else if (${allWrong}) {`);
|
|
266
|
+
lines.push(` errors.push({ message: ${JSON.stringify(`must be ${label}`)}, path: ${path} })`);
|
|
267
|
+
}
|
|
268
|
+
lines.push(` }`);
|
|
269
|
+
}
|
|
270
|
+
else if (allWrong) {
|
|
271
|
+
lines.push(` if (${raw} !== undefined && (${allWrong})) {`);
|
|
272
|
+
lines.push(` errors.push({ message: ${JSON.stringify(`must be ${label}`)}, path: ${path} })`);
|
|
230
273
|
lines.push(` }`);
|
|
231
274
|
}
|
|
275
|
+
// Any sibling value constraints (e.g. `minLength` on a `["string","null"]`)
|
|
276
|
+
// still apply — each carries its own runtime-type guard, so it is a no-op for
|
|
277
|
+
// the values it does not target.
|
|
278
|
+
lines.push(...generateConstraintChecks(key, raw, path, propSchema, suffix, ctx));
|
|
232
279
|
return lines;
|
|
233
280
|
}
|
|
234
281
|
// typed property
|
|
@@ -238,7 +285,7 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix, ctx) => {
|
|
|
238
285
|
const typLabel = typeofString(t);
|
|
239
286
|
if (isRequired) {
|
|
240
287
|
lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
|
|
241
|
-
lines.push(` errors.push({ message:
|
|
288
|
+
lines.push(` errors.push({ message: ${JSON.stringify(`must have required property '${key}'`)}, path: ${parentPath} })`);
|
|
242
289
|
if (wrongType) {
|
|
243
290
|
lines.push(` } else if (${wrongType}) {`);
|
|
244
291
|
lines.push(` errors.push({ message: 'must be ${typLabel}', path: ${path} })`);
|
|
@@ -267,7 +314,7 @@ const generatePropertyChecks = (key, propSchema, isRequired, suffix, ctx) => {
|
|
|
267
314
|
if (!hasType(propSchema) && isRequired) {
|
|
268
315
|
// No `type` to anchor a missing-property check, so enforce presence here.
|
|
269
316
|
lines.push(` if (!(${JSON.stringify(key)} in ${ctx.objVar})) {`);
|
|
270
|
-
lines.push(` errors.push({ message:
|
|
317
|
+
lines.push(` errors.push({ message: ${JSON.stringify(`must have required property '${key}'`)}, path: ${parentPath} })`);
|
|
271
318
|
lines.push(` } else {`);
|
|
272
319
|
lines.push(...extraLines);
|
|
273
320
|
lines.push(` }`);
|
|
@@ -353,7 +400,7 @@ const generateConstraintChecks = (key, raw, path, propSchema, suffix, ctx) => {
|
|
|
353
400
|
lines.push(` }`);
|
|
354
401
|
}
|
|
355
402
|
if (hasMultipleOf(propSchema)) {
|
|
356
|
-
lines.push(` if (typeof ${raw} === 'number' && ${raw
|
|
403
|
+
lines.push(` if (typeof ${raw} === 'number' && ${multipleOfFailExpr(raw, propSchema.multipleOf)}) {`);
|
|
357
404
|
lines.push(` errors.push({ message: 'must be a multiple of ${propSchema.multipleOf}', path: ${path} })`);
|
|
358
405
|
lines.push(` }`);
|
|
359
406
|
}
|
|
@@ -496,7 +543,7 @@ const generateValueChecks = (key, raw, path, propSchema, suffix, ctx) => {
|
|
|
496
543
|
const allowed = JSON.stringify(propSchema.enum);
|
|
497
544
|
const label = propSchema.enum.map((v) => JSON.stringify(v)).join(', ');
|
|
498
545
|
lines.push(` if (${raw} !== undefined && !(${allowed} as unknown[]).includes(${raw})) {`);
|
|
499
|
-
lines.push(` errors.push({ message:
|
|
546
|
+
lines.push(` errors.push({ message: ${JSON.stringify(`must be one of: ${label}`)}, path: ${path} })`);
|
|
500
547
|
lines.push(` }`);
|
|
501
548
|
return lines;
|
|
502
549
|
}
|
|
@@ -1098,7 +1145,7 @@ const booleanLeafExpr = (schema, acc) => {
|
|
|
1098
1145
|
if (hasExclusiveMaximum(schema))
|
|
1099
1146
|
parts.push(`!(${acc} >= ${schema.exclusiveMaximum})`);
|
|
1100
1147
|
if (hasMultipleOf(schema))
|
|
1101
|
-
parts.push(
|
|
1148
|
+
parts.push(multipleOfPassExpr(acc, schema.multipleOf));
|
|
1102
1149
|
return parts.join(' && ');
|
|
1103
1150
|
}
|
|
1104
1151
|
case 'boolean':
|
|
@@ -1305,7 +1352,7 @@ const generateScalarValidator = (schema, typeName, suffix) => {
|
|
|
1305
1352
|
return [
|
|
1306
1353
|
`export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
|
|
1307
1354
|
` if (!(${allowed} as unknown[]).includes(input)) {`,
|
|
1308
|
-
` return { valid: false, errors: [{ message:
|
|
1355
|
+
` return { valid: false, errors: [{ message: ${JSON.stringify(`must be one of: ${label}`)}, path: _path }] }`,
|
|
1309
1356
|
` }`,
|
|
1310
1357
|
` return true`,
|
|
1311
1358
|
`}`,
|
|
@@ -1327,31 +1374,48 @@ const generateScalarValidator = (schema, typeName, suffix) => {
|
|
|
1327
1374
|
`}`,
|
|
1328
1375
|
].join('\n');
|
|
1329
1376
|
}
|
|
1377
|
+
// Top-level multi-type / nullable schema (array `type`, e.g. `["string","null"]`).
|
|
1378
|
+
// `hasType` is false for an array `type`, so without this a root multi-type
|
|
1379
|
+
// schema falls through to the final `return true` and validates NOTHING. The
|
|
1380
|
+
// value is valid when it matches any listed type.
|
|
1381
|
+
const rootTypeArray = getTypeArray(schema);
|
|
1382
|
+
if (rootTypeArray) {
|
|
1383
|
+
const allWrong = rootTypeArray
|
|
1384
|
+
.map((t) => wrongTypeCondition('input', t))
|
|
1385
|
+
.filter((c) => c !== '')
|
|
1386
|
+
.map((c) => `(${c})`)
|
|
1387
|
+
.join(' && ');
|
|
1388
|
+
const label = rootTypeArray.map((t) => typeofString(t)).join(' or ');
|
|
1389
|
+
if (!allWrong) {
|
|
1390
|
+
return [
|
|
1391
|
+
`export const ${vName} = (_input: unknown, _path = ''): ValidationResult => {`,
|
|
1392
|
+
` return true`,
|
|
1393
|
+
`}`,
|
|
1394
|
+
].join('\n');
|
|
1395
|
+
}
|
|
1396
|
+
return [
|
|
1397
|
+
`export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
|
|
1398
|
+
` if (${allWrong}) {`,
|
|
1399
|
+
` return { valid: false, errors: [{ message: ${JSON.stringify(`must be ${label}`)}, path: _path }] }`,
|
|
1400
|
+
` }`,
|
|
1401
|
+
` return true`,
|
|
1402
|
+
`}`,
|
|
1403
|
+
].join('\n');
|
|
1404
|
+
}
|
|
1330
1405
|
// Top-level typed schema (string, number, boolean, array)
|
|
1331
1406
|
if (hasType(schema)) {
|
|
1332
1407
|
const t = schema.type;
|
|
1333
1408
|
const wrongType = wrongTypeCondition('input', t);
|
|
1334
1409
|
const typLabel = typeofString(t);
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
if (hasMinLength(schema)) {
|
|
1345
|
-
constraintLines.push(` if (typeof input === 'string' && input.length < ${schema.minLength}) {`);
|
|
1346
|
-
constraintLines.push(` errors.push({ message: 'must have at least ${schema.minLength} characters', path: _path })`);
|
|
1347
|
-
constraintLines.push(` }`);
|
|
1348
|
-
}
|
|
1349
|
-
if (hasMaxLength(schema)) {
|
|
1350
|
-
constraintLines.push(` if (typeof input === 'string' && input.length > ${schema.maxLength}) {`);
|
|
1351
|
-
constraintLines.push(` errors.push({ message: 'must have at most ${schema.maxLength} characters', path: _path })`);
|
|
1352
|
-
constraintLines.push(` }`);
|
|
1353
|
-
}
|
|
1354
|
-
}
|
|
1410
|
+
// Reuse the shared constraint emitter — the per-property path already handles
|
|
1411
|
+
// string (pattern, min/maxLength), number/integer (bounds, multipleOf) and
|
|
1412
|
+
// array (items, min/maxItems, uniqueItems, contains, prefixItems). The root
|
|
1413
|
+
// path previously only built string constraints, so a `{type:'number',
|
|
1414
|
+
// minimum:5}` or `{type:'array', minItems:2}` root accepted invalid input.
|
|
1415
|
+
// `raw` is `input`; `path` is the root `_path` (as a template so the shared
|
|
1416
|
+
// emitter's `path.slice(1,-1)` for array-item indices still works).
|
|
1417
|
+
const rootCtx = createRootContext();
|
|
1418
|
+
const constraintLines = generateConstraintChecks('', 'input', '`${_path}`', schema, suffix, rootCtx);
|
|
1355
1419
|
if (!wrongType) {
|
|
1356
1420
|
return [
|
|
1357
1421
|
`export const ${vName} = (_input: unknown, _path = ''): ValidationResult => {`,
|
|
@@ -1369,8 +1433,11 @@ const generateScalarValidator = (schema, typeName, suffix) => {
|
|
|
1369
1433
|
`}`,
|
|
1370
1434
|
].join('\n');
|
|
1371
1435
|
}
|
|
1436
|
+
// Array-item / nested constraints can hoist module-level declarations (e.g. a
|
|
1437
|
+
// compiled known-keys set); emit them before the function so it references them.
|
|
1438
|
+
const hoistedBlock = rootCtx.hoisted.length > 0 ? `${rootCtx.hoisted.join('\n')}\n\n` : '';
|
|
1372
1439
|
return [
|
|
1373
|
-
|
|
1440
|
+
`${hoistedBlock}export const ${vName} = (input: unknown, _path = ''): ValidationResult => {`,
|
|
1374
1441
|
` if (${wrongType}) {`,
|
|
1375
1442
|
` return { valid: false, errors: [{ message: 'must be ${typLabel}', path: _path }] }`,
|
|
1376
1443
|
` }`,
|
|
@@ -1382,6 +1449,44 @@ const generateScalarValidator = (schema, typeName, suffix) => {
|
|
|
1382
1449
|
}
|
|
1383
1450
|
return [`export const ${vName} = (_input: unknown, _path = ''): ValidationResult => {`, ` return true`, `}`].join('\n');
|
|
1384
1451
|
};
|
|
1452
|
+
/**
|
|
1453
|
+
* Throws when a schema (anywhere in its subtree) uses a keyword this generator
|
|
1454
|
+
* does not implement but which *narrows* the set of valid documents. Today that
|
|
1455
|
+
* is `unevaluatedProperties` / `unevaluatedItems` with a constraining value
|
|
1456
|
+
* (`false` or a subschema). The generator has no support for them — only the
|
|
1457
|
+
* runtime interpreter does — so silently emitting a validator would produce one
|
|
1458
|
+
* that ACCEPTS documents the interpreter REJECTS: a wrong verdict, worse than an
|
|
1459
|
+
* error. `unevaluated*: true` is a no-op (it permits everything), so it is
|
|
1460
|
+
* allowed through.
|
|
1461
|
+
*
|
|
1462
|
+
* We deliberately throw rather than implement the keywords: doing them correctly
|
|
1463
|
+
* requires tracking which properties/items each combinator branch "evaluated",
|
|
1464
|
+
* which is a large, separate feature. Failing loudly at generation time surfaces
|
|
1465
|
+
* the gap instead of shipping a validator that lies.
|
|
1466
|
+
*/
|
|
1467
|
+
const assertNoUnsupportedKeywords = (schema, typeName) => {
|
|
1468
|
+
const visit = (node) => {
|
|
1469
|
+
if (typeof node !== 'object' || node === null)
|
|
1470
|
+
return;
|
|
1471
|
+
if (Array.isArray(node)) {
|
|
1472
|
+
for (const item of node)
|
|
1473
|
+
visit(item);
|
|
1474
|
+
return;
|
|
1475
|
+
}
|
|
1476
|
+
const record = node;
|
|
1477
|
+
for (const keyword of ['unevaluatedProperties', 'unevaluatedItems']) {
|
|
1478
|
+
// `true` permits everything → no constraint → safe to ignore.
|
|
1479
|
+
if (keyword in record && record[keyword] !== true) {
|
|
1480
|
+
throw new Error(`[${typeName}] unsupported keyword "${keyword}": the validator generator does not implement it and would ` +
|
|
1481
|
+
`silently accept documents the interpreter rejects. Validate this schema with the runtime interpreter, ` +
|
|
1482
|
+
`or remove the keyword.`);
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
for (const value of Object.values(record))
|
|
1486
|
+
visit(value);
|
|
1487
|
+
};
|
|
1488
|
+
visit(schema);
|
|
1489
|
+
};
|
|
1385
1490
|
/**
|
|
1386
1491
|
* Generates a TypeScript validator function from a JSON Schema.
|
|
1387
1492
|
*
|
|
@@ -1405,6 +1510,7 @@ const generateScalarValidator = (schema, typeName, suffix) => {
|
|
|
1405
1510
|
* ```
|
|
1406
1511
|
*/
|
|
1407
1512
|
export const generateValidatorFunction = (schema, typeName, suffix = '') => {
|
|
1513
|
+
assertNoUnsupportedKeywords(schema, typeName);
|
|
1408
1514
|
if (isObjectSchema(schema)) {
|
|
1409
1515
|
return generateObjectValidator(schema, typeName, suffix);
|
|
1410
1516
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@amritk/generate-validators",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.2",
|
|
4
4
|
"description": "Generate TypeScript validation functions from JSON Schemas.",
|
|
5
5
|
"module": "./dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"json-schema-typed": "^8.0.1",
|
|
49
|
-
"@amritk/helpers": "0.10.
|
|
49
|
+
"@amritk/helpers": "0.10.2"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@ryoppippi/unplugin-typia": "^2.6.5",
|